@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)).
|
||||||
- Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)).
|
- Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)).
|
||||||
- Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)).
|
- Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)).
|
||||||
- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)).
|
- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)).
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.registerProvider("my-provider", {
|
pi.registerProvider("my-provider", {
|
||||||
name: "My Provider",
|
name: "My Provider",
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
apiKey: "MY_API_KEY",
|
apiKey: "$MY_API_KEY",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -83,7 +83,7 @@ pi.registerProvider("openai", {
|
|||||||
pi.registerProvider("google", {
|
pi.registerProvider("google", {
|
||||||
baseUrl: "https://ai-gateway.corp.com/google",
|
baseUrl: "https://ai-gateway.corp.com/google",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Corp-Auth": "CORP_AUTH_TOKEN" // env var or literal
|
"X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -112,7 +112,7 @@ export default async function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
pi.registerProvider("local-openai", {
|
pi.registerProvider("local-openai", {
|
||||||
baseUrl: "http://localhost:1234/v1",
|
baseUrl: "http://localhost:1234/v1",
|
||||||
apiKey: "LOCAL_OPENAI_API_KEY",
|
apiKey: "$LOCAL_OPENAI_API_KEY",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: payload.data.map((model) => ({
|
models: payload.data.map((model) => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@@ -132,7 +132,7 @@ This registers the fetched models before startup finishes.
|
|||||||
```typescript
|
```typescript
|
||||||
pi.registerProvider("my-llm", {
|
pi.registerProvider("my-llm", {
|
||||||
baseUrl: "https://api.my-llm.com/v1",
|
baseUrl: "https://api.my-llm.com/v1",
|
||||||
apiKey: "MY_LLM_API_KEY", // env var name or literal value
|
apiKey: "$MY_LLM_API_KEY", // env var reference
|
||||||
api: "openai-completions", // which streaming API to use
|
api: "openai-completions", // which streaming API to use
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -155,6 +155,8 @@ pi.registerProvider("my-llm", {
|
|||||||
|
|
||||||
When `models` is provided, it **replaces** all existing models for that provider.
|
When `models` is provided, it **replaces** all existing models for that provider.
|
||||||
|
|
||||||
|
`apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`.
|
||||||
|
|
||||||
## Unregister Provider
|
## Unregister Provider
|
||||||
|
|
||||||
Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`:
|
Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`:
|
||||||
@@ -163,7 +165,7 @@ Use `pi.unregisterProvider(name)` to remove a provider that was previously regis
|
|||||||
// Register
|
// Register
|
||||||
pi.registerProvider("my-llm", {
|
pi.registerProvider("my-llm", {
|
||||||
baseUrl: "https://api.my-llm.com/v1",
|
baseUrl: "https://api.my-llm.com/v1",
|
||||||
apiKey: "MY_LLM_API_KEY",
|
apiKey: "$MY_LLM_API_KEY",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -243,7 +245,7 @@ If your provider expects `Authorization: Bearer <key>` but doesn't use a standar
|
|||||||
```typescript
|
```typescript
|
||||||
pi.registerProvider("custom-api", {
|
pi.registerProvider("custom-api", {
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
apiKey: "MY_API_KEY",
|
apiKey: "$MY_API_KEY",
|
||||||
authHeader: true, // adds Authorization: Bearer header
|
authHeader: true, // adds Authorization: Bearer header
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: [...]
|
models: [...]
|
||||||
@@ -592,7 +594,7 @@ Register your stream function:
|
|||||||
```typescript
|
```typescript
|
||||||
pi.registerProvider("my-provider", {
|
pi.registerProvider("my-provider", {
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
apiKey: "MY_API_KEY",
|
apiKey: "$MY_API_KEY",
|
||||||
api: "my-custom-api",
|
api: "my-custom-api",
|
||||||
models: [...],
|
models: [...],
|
||||||
streamSimple: streamMyProvider
|
streamSimple: streamMyProvider
|
||||||
@@ -629,7 +631,7 @@ interface ProviderConfig {
|
|||||||
/** API endpoint URL. Required when defining models. */
|
/** API endpoint URL. Required when defining models. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
||||||
/** API key or environment variable name. Required when defining models (unless oauth). */
|
/** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
|
||||||
/** API type for streaming. Required at provider or model level when defining models. */
|
/** API type for streaming. Required at provider or model level when defining models. */
|
||||||
@@ -642,7 +644,7 @@ interface ProviderConfig {
|
|||||||
options?: SimpleStreamOptions
|
options?: SimpleStreamOptions
|
||||||
) => AssistantMessageEventStream;
|
) => AssistantMessageEventStream;
|
||||||
|
|
||||||
/** Custom headers to include in requests. Values can be env var names. */
|
/** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
/** If true, adds Authorization: Bearer header with the resolved API key. */
|
/** If true, adds Authorization: Bearer header with the resolved API key. */
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export default async function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
pi.registerProvider("local-openai", {
|
pi.registerProvider("local-openai", {
|
||||||
baseUrl: "http://localhost:1234/v1",
|
baseUrl: "http://localhost:1234/v1",
|
||||||
apiKey: "LOCAL_OPENAI_API_KEY",
|
apiKey: "$LOCAL_OPENAI_API_KEY",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: payload.data.map((model) => ({
|
models: payload.data.map((model) => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@@ -1555,7 +1555,7 @@ If you need to discover models from a remote endpoint, prefer an async extension
|
|||||||
pi.registerProvider("my-proxy", {
|
pi.registerProvider("my-proxy", {
|
||||||
name: "My Proxy",
|
name: "My Proxy",
|
||||||
baseUrl: "https://proxy.example.com",
|
baseUrl: "https://proxy.example.com",
|
||||||
apiKey: "PROXY_API_KEY", // env var name or literal
|
apiKey: "$PROXY_API_KEY", // env var reference
|
||||||
api: "anthropic-messages",
|
api: "anthropic-messages",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -1602,7 +1602,7 @@ pi.registerProvider("corporate-ai", {
|
|||||||
**Config options:**
|
**Config options:**
|
||||||
- `name` - Display name for the provider in UI such as `/login`.
|
- `name` - Display name for the provider in UI such as `/login`.
|
||||||
- `baseUrl` - API endpoint URL. Required when defining models.
|
- `baseUrl` - API endpoint URL. Required when defining models.
|
||||||
- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided).
|
- `apiKey` - API key literal, environment interpolation (`$ENV_VAR` or `${ENV_VAR}`), or leading `!command`. Required when defining models (unless `oauth` provided). `$$` escapes `$`, and `$!` escapes a literal `!` without triggering command execution.
|
||||||
- `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc.
|
- `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc.
|
||||||
- `headers` - Custom headers to include in requests.
|
- `headers` - Custom headers to include in requests.
|
||||||
- `authHeader` - If true, adds `Authorization: Bearer` header automatically.
|
- `authHeader` - If true, adds `Authorization: Bearer` header automatically.
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ Use `google-generative-ai` with a `baseUrl` to add models from Google AI Studio,
|
|||||||
"my-google": {
|
"my-google": {
|
||||||
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
|
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
"api": "google-generative-ai",
|
"api": "google-generative-ai",
|
||||||
"apiKey": "GEMINI_API_KEY",
|
"apiKey": "$GEMINI_API_KEY",
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"id": "gemma-4-31b-it",
|
"id": "gemma-4-31b-it",
|
||||||
@@ -143,22 +143,31 @@ Set `api` at provider level (default for all models) or model level (override pe
|
|||||||
|
|
||||||
### Value Resolution
|
### Value Resolution
|
||||||
|
|
||||||
The `apiKey` and `headers` fields support three formats:
|
The `apiKey` and `headers` fields support command execution, environment interpolation, and literals:
|
||||||
|
|
||||||
- **Shell command:** `"!command"` executes and uses stdout
|
- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout
|
||||||
```json
|
```json
|
||||||
"apiKey": "!security find-generic-password -ws 'anthropic'"
|
"apiKey": "!security find-generic-password -ws 'anthropic'"
|
||||||
"apiKey": "!op read 'op://vault/item/credential'"
|
"apiKey": "!op read 'op://vault/item/credential'"
|
||||||
```
|
```
|
||||||
- **Environment variable:** Uses the value of the named variable
|
- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals.
|
||||||
```json
|
```json
|
||||||
"apiKey": "MY_API_KEY"
|
"apiKey": "$MY_API_KEY"
|
||||||
|
"apiKey": "${KEY_PREFIX}_${KEY_SUFFIX}"
|
||||||
|
```
|
||||||
|
`$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.
|
||||||
|
- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution.
|
||||||
|
```json
|
||||||
|
"apiKey": "$$literal-dollar-prefix"
|
||||||
|
"apiKey": "$!literal-bang-prefix"
|
||||||
```
|
```
|
||||||
- **Literal value:** Used directly
|
- **Literal value:** Used directly
|
||||||
```json
|
```json
|
||||||
"apiKey": "sk-..."
|
"apiKey": "sk-..."
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup.
|
||||||
|
|
||||||
For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one.
|
For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one.
|
||||||
|
|
||||||
If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want.
|
If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want.
|
||||||
@@ -172,10 +181,10 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
|
|||||||
"providers": {
|
"providers": {
|
||||||
"custom-proxy": {
|
"custom-proxy": {
|
||||||
"baseUrl": "https://proxy.example.com/v1",
|
"baseUrl": "https://proxy.example.com/v1",
|
||||||
"apiKey": "MY_API_KEY",
|
"apiKey": "$MY_API_KEY",
|
||||||
"api": "anthropic-messages",
|
"api": "anthropic-messages",
|
||||||
"headers": {
|
"headers": {
|
||||||
"x-portkey-api-key": "PORTKEY_API_KEY",
|
"x-portkey-api-key": "$PORTKEY_API_KEY",
|
||||||
"x-secret": "!op read 'op://vault/item/secret'"
|
"x-secret": "!op read 'op://vault/item/secret'"
|
||||||
},
|
},
|
||||||
"models": [...]
|
"models": [...]
|
||||||
@@ -268,7 +277,7 @@ To merge custom models into a built-in provider, include the `models` array:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"baseUrl": "https://my-proxy.example.com/v1",
|
"baseUrl": "https://my-proxy.example.com/v1",
|
||||||
"apiKey": "ANTHROPIC_API_KEY",
|
"apiKey": "$ANTHROPIC_API_KEY",
|
||||||
"api": "anthropic-messages",
|
"api": "anthropic-messages",
|
||||||
"models": [...]
|
"models": [...]
|
||||||
}
|
}
|
||||||
@@ -327,7 +336,7 @@ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plu
|
|||||||
"anthropic-proxy": {
|
"anthropic-proxy": {
|
||||||
"baseUrl": "https://proxy.example.com",
|
"baseUrl": "https://proxy.example.com",
|
||||||
"api": "anthropic-messages",
|
"api": "anthropic-messages",
|
||||||
"apiKey": "ANTHROPIC_PROXY_KEY",
|
"apiKey": "$ANTHROPIC_PROXY_KEY",
|
||||||
"compat": {
|
"compat": {
|
||||||
"supportsEagerToolInputStreaming": false,
|
"supportsEagerToolInputStreaming": false,
|
||||||
"supportsLongCacheRetention": true,
|
"supportsLongCacheRetention": true,
|
||||||
@@ -405,7 +414,7 @@ Example:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"baseUrl": "https://openrouter.ai/api/v1",
|
"baseUrl": "https://openrouter.ai/api/v1",
|
||||||
"apiKey": "OPENROUTER_API_KEY",
|
"apiKey": "$OPENROUTER_API_KEY",
|
||||||
"api": "openai-completions",
|
"api": "openai-completions",
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
@@ -455,7 +464,7 @@ Vercel AI Gateway example:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"vercel-ai-gateway": {
|
"vercel-ai-gateway": {
|
||||||
"baseUrl": "https://ai-gateway.vercel.sh/v1",
|
"baseUrl": "https://ai-gateway.vercel.sh/v1",
|
||||||
"apiKey": "AI_GATEWAY_API_KEY",
|
"apiKey": "$AI_GATEWAY_API_KEY",
|
||||||
"api": "openai-completions",
|
"api": "openai-completions",
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -101,23 +101,31 @@ The file is created with `0600` permissions (user read/write only). Auth file cr
|
|||||||
|
|
||||||
### Key Resolution
|
### Key Resolution
|
||||||
|
|
||||||
The `key` field supports three formats:
|
The `key` field supports command execution, environment interpolation, and literals:
|
||||||
|
|
||||||
- **Shell command:** `"!command"` executes and uses stdout (cached for process lifetime)
|
- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout (cached for process lifetime)
|
||||||
```json
|
```json
|
||||||
{ "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" }
|
{ "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" }
|
||||||
{ "type": "api_key", "key": "!op read 'op://vault/item/credential'" }
|
{ "type": "api_key", "key": "!op read 'op://vault/item/credential'" }
|
||||||
```
|
```
|
||||||
- **Environment variable:** Uses the value of the named variable
|
- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals.
|
||||||
```json
|
```json
|
||||||
{ "type": "api_key", "key": "MY_ANTHROPIC_KEY" }
|
{ "type": "api_key", "key": "$MY_ANTHROPIC_KEY" }
|
||||||
|
{ "type": "api_key", "key": "${KEY_PREFIX}_${KEY_SUFFIX}" }
|
||||||
|
```
|
||||||
|
`$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.
|
||||||
|
- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution.
|
||||||
|
```json
|
||||||
|
{ "type": "api_key", "key": "$$literal-dollar-prefix" }
|
||||||
|
{ "type": "api_key", "key": "$!literal-bang-prefix" }
|
||||||
```
|
```
|
||||||
- **Literal value:** Used directly
|
- **Literal value:** Used directly
|
||||||
```json
|
```json
|
||||||
{ "type": "api_key", "key": "sk-ant-..." }
|
{ "type": "api_key", "key": "sk-ant-..." }
|
||||||
|
{ "type": "api_key", "key": "public" }
|
||||||
```
|
```
|
||||||
|
|
||||||
OAuth credentials are also stored here after `/login` and managed automatically.
|
Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. OAuth credentials are also stored here after `/login` and managed automatically.
|
||||||
|
|
||||||
## Cloud Providers
|
## Cloud Providers
|
||||||
|
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ function streamCustomAnthropic(
|
|||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
pi.registerProvider("custom-anthropic", {
|
pi.registerProvider("custom-anthropic", {
|
||||||
baseUrl: "https://api.anthropic.com",
|
baseUrl: "https://api.anthropic.com",
|
||||||
apiKey: "CUSTOM_ANTHROPIC_API_KEY",
|
apiKey: "$CUSTOM_ANTHROPIC_API_KEY",
|
||||||
api: "custom-anthropic-api",
|
api: "custom-anthropic-api",
|
||||||
|
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export function streamGitLabDuo(
|
|||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
pi.registerProvider("gitlab-duo", {
|
pi.registerProvider("gitlab-duo", {
|
||||||
baseUrl: AI_GATEWAY_URL,
|
baseUrl: AI_GATEWAY_URL,
|
||||||
apiKey: "GITLAB_TOKEN",
|
apiKey: "$GITLAB_TOKEN",
|
||||||
api: "gitlab-duo-api",
|
api: "gitlab-duo-api",
|
||||||
models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({
|
models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1256,7 +1256,7 @@ export interface ExtensionAPI {
|
|||||||
* // Register a new provider with custom models
|
* // Register a new provider with custom models
|
||||||
* pi.registerProvider("my-proxy", {
|
* pi.registerProvider("my-proxy", {
|
||||||
* baseUrl: "https://proxy.example.com",
|
* baseUrl: "https://proxy.example.com",
|
||||||
* apiKey: "PROXY_API_KEY",
|
* apiKey: "$PROXY_API_KEY",
|
||||||
* api: "anthropic-messages",
|
* api: "anthropic-messages",
|
||||||
* models: [
|
* models: [
|
||||||
* {
|
* {
|
||||||
@@ -1322,7 +1322,7 @@ export interface ProviderConfig {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Base URL for the API endpoint. Required when defining models. */
|
/** Base URL for the API endpoint. Required when defining models. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** API key or environment variable name. Required when defining models (unless oauth provided). */
|
/** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
/** API type. Required at provider or model level when defining models. */
|
/** API type. Required at provider or model level when defining models. */
|
||||||
api?: Api;
|
api?: Api;
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ import { type Static, Type } from "typebox";
|
|||||||
import { Compile } from "typebox/compile";
|
import { Compile } from "typebox/compile";
|
||||||
import type { TLocalizedValidationError } from "typebox/error";
|
import type { TLocalizedValidationError } from "typebox/error";
|
||||||
import { getAgentDir } from "../config.ts";
|
import { getAgentDir } from "../config.ts";
|
||||||
|
import { warnDeprecation } from "../utils/deprecation.ts";
|
||||||
|
import { stripJsonComments } from "../utils/json.ts";
|
||||||
import { normalizePath } from "../utils/paths.ts";
|
import { normalizePath } from "../utils/paths.ts";
|
||||||
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
|
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
|
||||||
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
|
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
|
||||||
import {
|
import {
|
||||||
clearConfigValueCache,
|
clearConfigValueCache,
|
||||||
|
getConfigValueEnvVarNames,
|
||||||
|
isCommandConfigValue,
|
||||||
|
isConfigValueConfigured,
|
||||||
|
isLegacyEnvVarNameConfigValue,
|
||||||
resolveConfigValueOrThrow,
|
resolveConfigValueOrThrow,
|
||||||
resolveConfigValueUncached,
|
resolveConfigValueUncached,
|
||||||
resolveHeadersOrThrow,
|
resolveHeadersOrThrow,
|
||||||
@@ -218,13 +224,6 @@ function formatValidationPath(error: TLocalizedValidationError): string {
|
|||||||
return path || "root";
|
return path || "root";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
|
|
||||||
function stripJsonComments(input: string): string {
|
|
||||||
return input
|
|
||||||
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
|
|
||||||
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provider override config (baseUrl, compat) without request auth/headers */
|
/** Provider override config (baseUrl, compat) without request auth/headers */
|
||||||
interface ProviderOverride {
|
interface ProviderOverride {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -237,6 +236,77 @@ interface ProviderRequestConfig {
|
|||||||
authHeader?: boolean;
|
authHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLegacyRegisterProviderConfigValue(providerName: string, field: string, value: string): string {
|
||||||
|
if (!isLegacyEnvVarNameConfigValue(value)) return value;
|
||||||
|
warnDeprecation(
|
||||||
|
`registerProvider("${providerName}") ${field} value "${value}" is treated as a legacy environment variable reference. This will no longer be detected as an environment variable reference in a future release. Pass "$${value}" instead.`,
|
||||||
|
);
|
||||||
|
return `$${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyRegisterProviderHeaders(
|
||||||
|
providerName: string,
|
||||||
|
field: string,
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
): Record<string, string> | undefined {
|
||||||
|
if (!headers) return undefined;
|
||||||
|
let migratedHeaders: Record<string, string> | undefined;
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
const migratedValue = migrateLegacyRegisterProviderConfigValue(providerName, `${field} header "${key}"`, value);
|
||||||
|
if (migratedValue === value) continue;
|
||||||
|
migratedHeaders ??= { ...headers };
|
||||||
|
migratedHeaders[key] = migratedValue;
|
||||||
|
}
|
||||||
|
return migratedHeaders ?? headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyRegisterProviderConfigValues(
|
||||||
|
providerName: string,
|
||||||
|
config: ProviderConfigInput,
|
||||||
|
): ProviderConfigInput {
|
||||||
|
let migratedConfig: ProviderConfigInput | undefined;
|
||||||
|
|
||||||
|
const setMigratedConfigValue = <TKey extends keyof ProviderConfigInput>(
|
||||||
|
key: TKey,
|
||||||
|
value: ProviderConfigInput[TKey],
|
||||||
|
) => {
|
||||||
|
migratedConfig ??= { ...config };
|
||||||
|
migratedConfig[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.apiKey) {
|
||||||
|
const apiKey = migrateLegacyRegisterProviderConfigValue(providerName, "apiKey", config.apiKey);
|
||||||
|
if (apiKey !== config.apiKey) {
|
||||||
|
setMigratedConfigValue("apiKey", apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = migrateLegacyRegisterProviderHeaders(providerName, "headers", config.headers);
|
||||||
|
if (headers !== config.headers) {
|
||||||
|
setMigratedConfigValue("headers", headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.models) {
|
||||||
|
let models: ProviderConfigInput["models"] | undefined;
|
||||||
|
for (let index = 0; index < config.models.length; index++) {
|
||||||
|
const model = config.models[index];
|
||||||
|
const modelHeaders = migrateLegacyRegisterProviderHeaders(
|
||||||
|
providerName,
|
||||||
|
`model "${model.id}" headers`,
|
||||||
|
model.headers,
|
||||||
|
);
|
||||||
|
if (modelHeaders === model.headers) continue;
|
||||||
|
models ??= [...config.models];
|
||||||
|
models[index] = { ...model, headers: modelHeaders };
|
||||||
|
}
|
||||||
|
if (models) {
|
||||||
|
setMigratedConfigValue("models", models);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migratedConfig ?? config;
|
||||||
|
}
|
||||||
|
|
||||||
export type ResolvedRequestAuth =
|
export type ResolvedRequestAuth =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
@@ -641,9 +711,10 @@ export class ModelRegistry {
|
|||||||
* Get API key for a model.
|
* Get API key for a model.
|
||||||
*/
|
*/
|
||||||
hasConfiguredAuth(model: Model<Api>): boolean {
|
hasConfiguredAuth(model: Model<Api>): boolean {
|
||||||
|
const providerApiKey = this.providerRequestConfigs.get(model.provider)?.apiKey;
|
||||||
return (
|
return (
|
||||||
this.authStorage.hasAuth(model.provider) ||
|
this.authStorage.hasAuth(model.provider) ||
|
||||||
this.providerRequestConfigs.get(model.provider)?.apiKey !== undefined
|
(providerApiKey !== undefined && isConfigValueConfigured(providerApiKey))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,12 +809,15 @@ export class ModelRegistry {
|
|||||||
return authStatus;
|
return authStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerApiKey.startsWith("!")) {
|
if (isCommandConfigValue(providerApiKey)) {
|
||||||
return { configured: true, source: "models_json_command" };
|
return { configured: true, source: "models_json_command" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env[providerApiKey]) {
|
const envVarNames = getConfigValueEnvVarNames(providerApiKey);
|
||||||
return { configured: true, source: "environment", label: providerApiKey };
|
if (envVarNames.length > 0) {
|
||||||
|
return isConfigValueConfigured(providerApiKey)
|
||||||
|
? { configured: true, source: "environment", label: envVarNames.join(", ") }
|
||||||
|
: { configured: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { configured: true, source: "models_json_key" };
|
return { configured: true, source: "models_json_key" };
|
||||||
@@ -794,9 +868,10 @@ export class ModelRegistry {
|
|||||||
* If provider has oauth: registers OAuth provider for /login support.
|
* If provider has oauth: registers OAuth provider for /login support.
|
||||||
*/
|
*/
|
||||||
registerProvider(providerName: string, config: ProviderConfigInput): void {
|
registerProvider(providerName: string, config: ProviderConfigInput): void {
|
||||||
this.validateProviderConfig(providerName, config);
|
const migratedConfig = migrateLegacyRegisterProviderConfigValues(providerName, config);
|
||||||
this.applyProviderConfig(providerName, config);
|
this.validateProviderConfig(providerName, migratedConfig);
|
||||||
this.upsertRegisteredProvider(providerName, config);
|
this.applyProviderConfig(providerName, migratedConfig);
|
||||||
|
this.upsertRegisteredProvider(providerName, migratedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,18 +8,151 @@ import { getShellConfig } from "../utils/shell.ts";
|
|||||||
|
|
||||||
// Cache for shell command results (persists for process lifetime)
|
// Cache for shell command results (persists for process lifetime)
|
||||||
const commandResultCache = new Map<string, string | undefined>();
|
const commandResultCache = new Map<string, string | undefined>();
|
||||||
|
const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
|
||||||
|
const LEGACY_ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
||||||
|
|
||||||
|
type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string };
|
||||||
|
|
||||||
|
type ConfigValueReference = { type: "command"; config: string } | { type: "template"; parts: TemplatePart[] };
|
||||||
|
|
||||||
|
function appendLiteral(parts: TemplatePart[], value: string): void {
|
||||||
|
if (!value) return;
|
||||||
|
const previousPart = parts[parts.length - 1];
|
||||||
|
if (previousPart?.type === "literal") {
|
||||||
|
previousPart.value += value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parts.push({ type: "literal", value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigValueTemplate(config: string): TemplatePart[] {
|
||||||
|
const parts: TemplatePart[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < config.length) {
|
||||||
|
const dollarIndex = config.indexOf("$", index);
|
||||||
|
if (dollarIndex < 0) {
|
||||||
|
appendLiteral(parts, config.slice(index));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLiteral(parts, config.slice(index, dollarIndex));
|
||||||
|
const nextChar = config[dollarIndex + 1];
|
||||||
|
|
||||||
|
if (nextChar === "$" || nextChar === "!") {
|
||||||
|
appendLiteral(parts, nextChar);
|
||||||
|
index = dollarIndex + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextChar === "{") {
|
||||||
|
const endIndex = config.indexOf("}", dollarIndex + 2);
|
||||||
|
if (endIndex < 0) {
|
||||||
|
appendLiteral(parts, "$");
|
||||||
|
index = dollarIndex + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = config.slice(dollarIndex + 2, endIndex);
|
||||||
|
if (ENV_VAR_NAME_RE.test(name)) {
|
||||||
|
parts.push({ type: "env", name });
|
||||||
|
} else {
|
||||||
|
appendLiteral(parts, config.slice(dollarIndex, endIndex + 1));
|
||||||
|
}
|
||||||
|
index = endIndex + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_RE);
|
||||||
|
if (match) {
|
||||||
|
parts.push({ type: "env", name: match[0] });
|
||||||
|
index = dollarIndex + 1 + match[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLiteral(parts, "$");
|
||||||
|
index = dollarIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigValueReference(config: string): ConfigValueReference {
|
||||||
|
if (config.startsWith("!")) {
|
||||||
|
return { type: "command", config };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "template", parts: parseConfigValueTemplate(config) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvConfigValue(name: string): string | undefined {
|
||||||
|
return process.env[name] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplateEnvVarNames(parts: TemplatePart[]): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type !== "env" || names.includes(part.name)) continue;
|
||||||
|
names.push(part.name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTemplate(parts: TemplatePart[]): string | undefined {
|
||||||
|
let resolved = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "literal") {
|
||||||
|
resolved += part.value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const envValue = resolveEnvConfigValue(part.name);
|
||||||
|
if (envValue === undefined) return undefined;
|
||||||
|
resolved += envValue;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigValueEnvVarName(config: string): string | undefined {
|
||||||
|
const reference = parseConfigValueReference(config);
|
||||||
|
if (reference.type !== "template") return undefined;
|
||||||
|
return reference.parts.length === 1 && reference.parts[0]?.type === "env" ? reference.parts[0].name : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigValueEnvVarNames(config: string): string[] {
|
||||||
|
const reference = parseConfigValueReference(config);
|
||||||
|
return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMissingConfigValueEnvVarNames(config: string): string[] {
|
||||||
|
return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name) === undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCommandConfigValue(config: string): boolean {
|
||||||
|
return parseConfigValueReference(config).type === "command";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfigValueConfigured(config: string): boolean {
|
||||||
|
return getMissingConfigValueEnvVarNames(config).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacyEnvVarNameConfigValue(config: string): boolean {
|
||||||
|
return LEGACY_ENV_VAR_NAME_RE.test(config);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a config value (API key, header value, etc.) to an actual value.
|
* Resolve a config value (API key, header value, etc.) to an actual value.
|
||||||
* - If starts with "!", executes the rest as a shell command and uses stdout (cached)
|
* - If starts with "!", executes the rest as a shell command and uses stdout (cached)
|
||||||
* - Otherwise checks environment variable first, then treats as literal (not cached)
|
* - Interpolates "$ENV_VAR" or "${ENV_VAR}" references with the named environment variable
|
||||||
|
* - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!"
|
||||||
|
* - Otherwise treats the value as a literal
|
||||||
*/
|
*/
|
||||||
export function resolveConfigValue(config: string): string | undefined {
|
export function resolveConfigValue(config: string): string | undefined {
|
||||||
if (config.startsWith("!")) {
|
const reference = parseConfigValueReference(config);
|
||||||
return executeCommand(config);
|
if (reference.type === "command") {
|
||||||
|
return executeCommand(reference.config);
|
||||||
}
|
}
|
||||||
const envValue = process.env[config];
|
return resolveTemplate(reference.parts);
|
||||||
return envValue || config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } {
|
function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } {
|
||||||
@@ -89,11 +222,11 @@ function executeCommand(commandConfig: string): string | undefined {
|
|||||||
* Resolve all header values using the same resolution logic as API keys.
|
* Resolve all header values using the same resolution logic as API keys.
|
||||||
*/
|
*/
|
||||||
export function resolveConfigValueUncached(config: string): string | undefined {
|
export function resolveConfigValueUncached(config: string): string | undefined {
|
||||||
if (config.startsWith("!")) {
|
const reference = parseConfigValueReference(config);
|
||||||
return executeCommandUncached(config);
|
if (reference.type === "command") {
|
||||||
|
return executeCommandUncached(reference.config);
|
||||||
}
|
}
|
||||||
const envValue = process.env[config];
|
return resolveTemplate(reference.parts);
|
||||||
return envValue || config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfigValueOrThrow(config: string, description: string): string {
|
export function resolveConfigValueOrThrow(config: string, description: string): string {
|
||||||
@@ -102,8 +235,19 @@ export function resolveConfigValueOrThrow(config: string, description: string):
|
|||||||
return resolvedValue;
|
return resolvedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.startsWith("!")) {
|
const reference = parseConfigValueReference(config);
|
||||||
throw new Error(`Failed to resolve ${description} from shell command: ${config.slice(1)}`);
|
if (reference.type === "command") {
|
||||||
|
throw new Error(`Failed to resolve ${description} from shell command: ${reference.config.slice(1)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.type === "template") {
|
||||||
|
const missingEnvVars = getMissingConfigValueEnvVarNames(config);
|
||||||
|
if (missingEnvVars.length === 1) {
|
||||||
|
throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`);
|
||||||
|
}
|
||||||
|
if (missingEnvVars.length > 1) {
|
||||||
|
throw new Error(`Failed to resolve ${description} from environment variables: ${missingEnvVars.join(", ")}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to resolve ${description}`);
|
throw new Error(`Failed to resolve ${description}`);
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
|
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
|
||||||
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
|
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
|
||||||
|
import { isLegacyEnvVarNameConfigValue } from "./core/resolve-config-value.ts";
|
||||||
|
import { stripJsonComments } from "./utils/json.ts";
|
||||||
|
|
||||||
const MIGRATION_GUIDE_URL =
|
const MIGRATION_GUIDE_URL =
|
||||||
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
|
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
|
||||||
@@ -72,6 +74,135 @@ export function migrateAuthToAuthJson(): string[] {
|
|||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConfigValueMigration {
|
||||||
|
location: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyEnvVarString(value: string): string | undefined {
|
||||||
|
return isLegacyEnvVarNameConfigValue(value) ? `$${value}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateStringProperty(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
location: string,
|
||||||
|
migrations: ConfigValueMigration[],
|
||||||
|
): boolean {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
const migrated = migrateLegacyEnvVarString(value);
|
||||||
|
if (migrated === undefined) return false;
|
||||||
|
record[key] = migrated;
|
||||||
|
migrations.push({ location, from: value, to: migrated });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateHeadersConfig(headers: unknown, location: string, migrations: ConfigValueMigration[]): boolean {
|
||||||
|
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return false;
|
||||||
|
const headerRecord = headers as Record<string, unknown>;
|
||||||
|
let migrated = false;
|
||||||
|
for (const [key, value] of Object.entries(headerRecord)) {
|
||||||
|
if (typeof value !== "string") continue;
|
||||||
|
const migratedValue = migrateLegacyEnvVarString(value);
|
||||||
|
if (migratedValue === undefined) continue;
|
||||||
|
headerRecord[key] = migratedValue;
|
||||||
|
migrations.push({ location: `${location}[${JSON.stringify(key)}]`, from: value, to: migratedValue });
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateAuthJsonConfigValues(agentDir: string): ConfigValueMigration[] {
|
||||||
|
const authPath = join(agentDir, "auth.json");
|
||||||
|
if (!existsSync(authPath)) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
||||||
|
const authData = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
const migrations: ConfigValueMigration[] = [];
|
||||||
|
for (const [provider, credential] of Object.entries(authData)) {
|
||||||
|
if (typeof credential !== "object" || credential === null || Array.isArray(credential)) continue;
|
||||||
|
const credentialRecord = credential as Record<string, unknown>;
|
||||||
|
if (credentialRecord.type !== "api_key") continue;
|
||||||
|
migrateStringProperty(credentialRecord, "key", `auth.json[${JSON.stringify(provider)}].key`, migrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrations.length === 0) return [];
|
||||||
|
writeFileSync(authPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
|
||||||
|
chmodSync(authPath, 0o600);
|
||||||
|
return migrations;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] {
|
||||||
|
const modelsPath = join(agentDir, "models.json");
|
||||||
|
if (!existsSync(modelsPath)) return [];
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown;
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
||||||
|
const modelsData = parsed as Record<string, unknown>;
|
||||||
|
const providers = modelsData.providers;
|
||||||
|
if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return [];
|
||||||
|
|
||||||
|
const migrations: ConfigValueMigration[] = [];
|
||||||
|
for (const [provider, providerConfig] of Object.entries(providers)) {
|
||||||
|
if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue;
|
||||||
|
const providerRecord = providerConfig as Record<string, unknown>;
|
||||||
|
const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`;
|
||||||
|
migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations);
|
||||||
|
migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations);
|
||||||
|
|
||||||
|
if (Array.isArray(providerRecord.models)) {
|
||||||
|
for (let index = 0; index < providerRecord.models.length; index++) {
|
||||||
|
const modelConfig = providerRecord.models[index];
|
||||||
|
if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue;
|
||||||
|
const modelRecord = modelConfig as Record<string, unknown>;
|
||||||
|
const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index);
|
||||||
|
migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelOverrides = providerRecord.modelOverrides;
|
||||||
|
if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) {
|
||||||
|
for (const [modelId, modelOverride] of Object.entries(modelOverrides)) {
|
||||||
|
if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) continue;
|
||||||
|
const modelOverrideRecord = modelOverride as Record<string, unknown>;
|
||||||
|
migrateHeadersConfig(
|
||||||
|
modelOverrideRecord.headers,
|
||||||
|
`${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`,
|
||||||
|
migrations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrations.length === 0) return [];
|
||||||
|
writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
|
||||||
|
return migrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateExplicitEnvVarConfigValues(): void {
|
||||||
|
const agentDir = getAgentDir();
|
||||||
|
const migrations = [...migrateAuthJsonConfigValues(agentDir), ...migrateModelsJsonConfigValues(agentDir)];
|
||||||
|
if (migrations.length === 0) return;
|
||||||
|
|
||||||
|
const details = migrations.map((migration) => ` - ${migration.location}: ${migration.from} -> ${migration.to}`);
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(
|
||||||
|
[
|
||||||
|
"Warning: Migrated API key/header environment references to explicit $ENV_VAR syntax. Plain strings will be treated as literals.",
|
||||||
|
...details,
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories.
|
* Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories.
|
||||||
*
|
*
|
||||||
@@ -307,6 +438,7 @@ export function runMigrations(cwd: string): {
|
|||||||
deprecationWarnings: string[];
|
deprecationWarnings: string[];
|
||||||
} {
|
} {
|
||||||
const migratedAuthProviders = migrateAuthToAuthJson();
|
const migratedAuthProviders = migrateAuthToAuthJson();
|
||||||
|
migrateExplicitEnvVarConfigValues();
|
||||||
migrateSessionsFromAgentRoot();
|
migrateSessionsFromAgentRoot();
|
||||||
migrateToolsToBin();
|
migrateToolsToBin();
|
||||||
migrateKeybindingsConfigFile();
|
migrateKeybindingsConfigFile();
|
||||||
|
|||||||
14
packages/coding-agent/src/utils/deprecation.ts
Normal file
14
packages/coding-agent/src/utils/deprecation.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
const emittedDeprecationWarnings = new Set<string>();
|
||||||
|
|
||||||
|
export function warnDeprecation(message: string): void {
|
||||||
|
if (emittedDeprecationWarnings.has(message)) return;
|
||||||
|
emittedDeprecationWarnings.add(message);
|
||||||
|
console.warn(chalk.yellow(`Deprecation warning: ${message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear deprecation warning state. Exported for tests. */
|
||||||
|
export function clearDeprecationWarningsForTests(): void {
|
||||||
|
emittedDeprecationWarnings.clear();
|
||||||
|
}
|
||||||
6
packages/coding-agent/src/utils/json.ts
Normal file
6
packages/coding-agent/src/utils/json.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
|
||||||
|
export function stripJsonComments(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
|
||||||
|
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
|
||||||
|
}
|
||||||
@@ -112,13 +112,13 @@ describe("AuthStorage", () => {
|
|||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("apiKey as environment variable name resolves to env value", async () => {
|
test("apiKey with $ prefix resolves to env value", async () => {
|
||||||
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
|
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
|
||||||
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
|
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeAuthJson({
|
writeAuthJson({
|
||||||
anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" },
|
anthropic: { type: "api_key", key: "$TEST_AUTH_API_KEY_12345" },
|
||||||
});
|
});
|
||||||
|
|
||||||
authStorage = AuthStorage.create(authJsonPath);
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
@@ -134,6 +134,140 @@ describe("AuthStorage", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("apiKey with braced env syntax resolves to env value", async () => {
|
||||||
|
const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345;
|
||||||
|
process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value";
|
||||||
|
const bracedKey = "$" + "{TEST_AUTH_BRACED_API_KEY_12345}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeAuthJson({
|
||||||
|
anthropic: { type: "api_key", key: bracedKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
const apiKey = await authStorage.getApiKey("anthropic");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("braced-env-api-key-value");
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.TEST_AUTH_BRACED_API_KEY_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_AUTH_BRACED_API_KEY_12345 = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey interpolates braced env references inside literals", async () => {
|
||||||
|
const originalPartA = process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
|
||||||
|
const originalPartB = process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
|
||||||
|
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = "left";
|
||||||
|
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = "right";
|
||||||
|
const interpolatedKey = [
|
||||||
|
"$",
|
||||||
|
"{TEST_AUTH_INTERPOLATED_PART_A_12345}_$",
|
||||||
|
"{TEST_AUTH_INTERPOLATED_PART_B_12345}",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeAuthJson({
|
||||||
|
anthropic: { type: "api_key", key: interpolatedKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
const apiKey = await authStorage.getApiKey("anthropic");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("left_right");
|
||||||
|
} finally {
|
||||||
|
if (originalPartA === undefined) {
|
||||||
|
delete process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = originalPartA;
|
||||||
|
}
|
||||||
|
if (originalPartB === undefined) {
|
||||||
|
delete process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = originalPartB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey with $$ prefix escapes a leading dollar", async () => {
|
||||||
|
writeAuthJson({
|
||||||
|
anthropic: { type: "api_key", key: "$$TEST_AUTH_API_KEY_12345" },
|
||||||
|
});
|
||||||
|
|
||||||
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
const apiKey = await authStorage.getApiKey("anthropic");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("$TEST_AUTH_API_KEY_12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => {
|
||||||
|
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
|
||||||
|
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeAuthJson({
|
||||||
|
anthropic: { type: "api_key", key: "$!literal-$TEST_AUTH_API_KEY_12345" },
|
||||||
|
});
|
||||||
|
|
||||||
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
const apiKey = await authStorage.getApiKey("anthropic");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("!literal-env-api-key-value");
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.TEST_AUTH_API_KEY_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain API key is used directly even when it matches an env var", async () => {
|
||||||
|
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
|
||||||
|
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeAuthJson({
|
||||||
|
anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" },
|
||||||
|
});
|
||||||
|
|
||||||
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
const apiKey = await authStorage.getApiKey("anthropic");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("TEST_AUTH_API_KEY_12345");
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.TEST_AUTH_API_KEY_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("literal public API key is not corrupted by the Windows PUBLIC env var", async () => {
|
||||||
|
const originalPublic = process.env.PUBLIC;
|
||||||
|
process.env.PUBLIC = "C:\\Users\\Public";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeAuthJson({
|
||||||
|
opencode: { type: "api_key", key: "public" },
|
||||||
|
});
|
||||||
|
|
||||||
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
const apiKey = await authStorage.getApiKey("opencode");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("public");
|
||||||
|
} finally {
|
||||||
|
if (originalPublic === undefined) {
|
||||||
|
delete process.env.PUBLIC;
|
||||||
|
} else {
|
||||||
|
process.env.PUBLIC = originalPublic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("apiKey as literal value is used directly when not an env var", async () => {
|
test("apiKey as literal value is used directly when not an env var", async () => {
|
||||||
// Make sure this isn't an env var
|
// Make sure this isn't an env var
|
||||||
delete process.env.literal_api_key_value;
|
delete process.env.literal_api_key_value;
|
||||||
@@ -274,7 +408,7 @@ describe("AuthStorage", () => {
|
|||||||
process.env[envVarName] = "first-value";
|
process.env[envVarName] = "first-value";
|
||||||
|
|
||||||
writeAuthJson({
|
writeAuthJson({
|
||||||
anthropic: { type: "api_key", key: envVarName },
|
anthropic: { type: "api_key", key: `$${envVarName}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
authStorage = AuthStorage.create(authJsonPath);
|
authStorage = AuthStorage.create(authJsonPath);
|
||||||
|
|||||||
138
packages/coding-agent/test/config-value-migration.test.ts
Normal file
138
packages/coding-agent/test/config-value-migration.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ENV_AGENT_DIR } from "../src/config.ts";
|
||||||
|
import { runMigrations } from "../src/migrations.ts";
|
||||||
|
|
||||||
|
describe("config value env var syntax migration", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createAgentDir(): string {
|
||||||
|
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-value-migration-test-"));
|
||||||
|
tempDirs.push(agentDir);
|
||||||
|
return agentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAgentDir(agentDir: string, fn: () => void): void {
|
||||||
|
const previousAgentDir = process.env[ENV_AGENT_DIR];
|
||||||
|
process.env[ENV_AGENT_DIR] = agentDir;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
if (previousAgentDir === undefined) {
|
||||||
|
delete process.env[ENV_AGENT_DIR];
|
||||||
|
} else {
|
||||||
|
process.env[ENV_AGENT_DIR] = previousAgentDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rewrites legacy uppercase auth.json API key values to explicit env references", () => {
|
||||||
|
const agentDir = createAgentDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(agentDir, "auth.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" },
|
||||||
|
openai: { type: "api_key", key: "$OPENAI_API_KEY" },
|
||||||
|
opencode: { type: "api_key", key: "public" },
|
||||||
|
github: { type: "oauth", access: "ACCESS_TOKEN", refresh: "REFRESH_TOKEN", expires: 1 },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
withAgentDir(agentDir, () => runMigrations(agentDir));
|
||||||
|
|
||||||
|
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8")) as Record<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
expect(migrated.anthropic.key).toBe("$ANTHROPIC_API_KEY");
|
||||||
|
expect(migrated.openai.key).toBe("$OPENAI_API_KEY");
|
||||||
|
expect(migrated.opencode.key).toBe("public");
|
||||||
|
expect(migrated.github.access).toBe("ACCESS_TOKEN");
|
||||||
|
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
|
||||||
|
expect(logMessage).toContain("explicit $ENV_VAR syntax");
|
||||||
|
expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites legacy uppercase models.json API key and header values", () => {
|
||||||
|
const agentDir = createAgentDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(agentDir, "models.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
providers: {
|
||||||
|
"custom-provider": {
|
||||||
|
baseUrl: "https://example.com/v1",
|
||||||
|
apiKey: "CUSTOM_API_KEY",
|
||||||
|
api: "openai-completions",
|
||||||
|
headers: {
|
||||||
|
"x-api-key": "HEADER_API_KEY",
|
||||||
|
"x-literal": "literal",
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "model-a",
|
||||||
|
headers: { "x-model-key": "MODEL_API_KEY" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modelOverrides: {
|
||||||
|
"model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
withAgentDir(agentDir, () => runMigrations(agentDir));
|
||||||
|
|
||||||
|
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as {
|
||||||
|
providers: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
apiKey?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
models?: Array<{ headers?: Record<string, string> }>;
|
||||||
|
modelOverrides?: Record<string, { headers?: Record<string, string> }>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
const provider = migrated.providers["custom-provider"]!;
|
||||||
|
expect(provider.apiKey).toBe("$CUSTOM_API_KEY");
|
||||||
|
expect(provider.headers?.["x-api-key"]).toBe("$HEADER_API_KEY");
|
||||||
|
expect(provider.headers?.["x-literal"]).toBe("literal");
|
||||||
|
expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("$MODEL_API_KEY");
|
||||||
|
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("$OVERRIDE_API_KEY");
|
||||||
|
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
|
||||||
|
expect(logMessage).toContain(
|
||||||
|
'models.json.providers["custom-provider"].apiKey: CUSTOM_API_KEY -> $CUSTOM_API_KEY',
|
||||||
|
);
|
||||||
|
expect(logMessage).toContain(
|
||||||
|
'models.json.providers["custom-provider"].headers["x-api-key"]: HEADER_API_KEY -> $HEADER_API_KEY',
|
||||||
|
);
|
||||||
|
expect(logMessage).toContain(
|
||||||
|
'models.json.providers["custom-provider"].models["model-a"].headers["x-model-key"]: MODEL_API_KEY -> $MODEL_API_KEY',
|
||||||
|
);
|
||||||
|
expect(logMessage).toContain(
|
||||||
|
'models.json.providers["custom-provider"].modelOverrides["model-b"].headers["x-override-key"]: OVERRIDE_API_KEY -> $OVERRIDE_API_KEY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,7 +36,7 @@ describe("ExtensionRunner", () => {
|
|||||||
|
|
||||||
const providerModelConfig: ProviderConfig = {
|
const providerModelConfig: ProviderConfig = {
|
||||||
baseUrl: "https://provider.test/v1",
|
baseUrl: "https://provider.test/v1",
|
||||||
apiKey: "PROVIDER_TEST_KEY",
|
apiKey: "provider-test-key",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { join } from "node:path";
|
|||||||
import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
||||||
import { getApiProvider } from "@earendil-works/pi-ai";
|
import { getApiProvider } from "@earendil-works/pi-ai";
|
||||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||||
import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts";
|
import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts";
|
||||||
|
import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts";
|
||||||
|
|
||||||
describe("ModelRegistry", () => {
|
describe("ModelRegistry", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -18,6 +19,7 @@ describe("ModelRegistry", () => {
|
|||||||
mkdirSync(tempDir, { recursive: true });
|
mkdirSync(tempDir, { recursive: true });
|
||||||
modelsJsonPath = join(tempDir, "models.json");
|
modelsJsonPath = join(tempDir, "models.json");
|
||||||
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||||
|
clearDeprecationWarningsForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -25,6 +27,8 @@ describe("ModelRegistry", () => {
|
|||||||
rmSync(tempDir, { recursive: true });
|
rmSync(tempDir, { recursive: true });
|
||||||
}
|
}
|
||||||
clearApiKeyCache();
|
clearApiKeyCache();
|
||||||
|
clearDeprecationWarningsForTests();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Create minimal provider config */
|
/** Create minimal provider config */
|
||||||
@@ -35,7 +39,7 @@ describe("ModelRegistry", () => {
|
|||||||
): ProviderConfigInput {
|
): ProviderConfigInput {
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey: "TEST_KEY",
|
apiKey: "test-key",
|
||||||
api: api as Api,
|
api: api as Api,
|
||||||
models: models.map((m) => ({
|
models: models.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -852,7 +856,7 @@ describe("ModelRegistry", () => {
|
|||||||
registry.registerProvider("named-provider", {
|
registry.registerProvider("named-provider", {
|
||||||
name: "Named Provider",
|
name: "Named Provider",
|
||||||
baseUrl: "https://provider.test/v1",
|
baseUrl: "https://provider.test/v1",
|
||||||
apiKey: "TEST_KEY",
|
apiKey: "test-key",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -892,6 +896,30 @@ describe("ModelRegistry", () => {
|
|||||||
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
|
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => {
|
||||||
|
const originalEnv = process.env.CUSTOM_NAME;
|
||||||
|
process.env.CUSTOM_NAME = "legacy-env-key";
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
|
||||||
|
registry.registerProvider("legacy-provider", {
|
||||||
|
...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"),
|
||||||
|
apiKey: "CUSTOM_NAME",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await registry.getApiKeyForProvider("legacy-provider")).toBe("legacy-env-key");
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead'));
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.CUSTOM_NAME;
|
||||||
|
} else {
|
||||||
|
process.env.CUSTOM_NAME = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("failed registerProvider does not persist invalid streamSimple config", () => {
|
test("failed registerProvider does not persist invalid streamSimple config", () => {
|
||||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
|
||||||
@@ -911,7 +939,7 @@ describe("ModelRegistry", () => {
|
|||||||
|
|
||||||
registry.registerProvider("demo-provider", {
|
registry.registerProvider("demo-provider", {
|
||||||
baseUrl: "https://provider.test/v1",
|
baseUrl: "https://provider.test/v1",
|
||||||
apiKey: "TEST_KEY",
|
apiKey: "test-key",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -931,7 +959,7 @@ describe("ModelRegistry", () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
registry.registerProvider("demo-provider", {
|
registry.registerProvider("demo-provider", {
|
||||||
baseUrl: "https://provider.test/v2",
|
baseUrl: "https://provider.test/v2",
|
||||||
apiKey: "TEST_KEY",
|
apiKey: "test-key",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "broken-model",
|
id: "broken-model",
|
||||||
@@ -1187,7 +1215,117 @@ describe("ModelRegistry", () => {
|
|||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("apiKey as environment variable name resolves to env value", async () => {
|
test("apiKey with $ prefix resolves to env value", async () => {
|
||||||
|
const originalEnv = process.env.TEST_API_KEY_12345;
|
||||||
|
process.env.TEST_API_KEY_12345 = "env-api-key-value";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey("$TEST_API_KEY_12345"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("env-api-key-value");
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.TEST_API_KEY_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_API_KEY_12345 = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey with braced env syntax resolves to env value", async () => {
|
||||||
|
const originalEnv = process.env.TEST_BRACED_API_KEY_12345;
|
||||||
|
process.env.TEST_BRACED_API_KEY_12345 = "braced-env-api-key-value";
|
||||||
|
const bracedKey = "$" + "{TEST_BRACED_API_KEY_12345}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey(bracedKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("braced-env-api-key-value");
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.TEST_BRACED_API_KEY_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_BRACED_API_KEY_12345 = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey interpolates braced env references inside literals", async () => {
|
||||||
|
const originalPartA = process.env.TEST_INTERPOLATED_PART_A_12345;
|
||||||
|
const originalPartB = process.env.TEST_INTERPOLATED_PART_B_12345;
|
||||||
|
process.env.TEST_INTERPOLATED_PART_A_12345 = "left";
|
||||||
|
process.env.TEST_INTERPOLATED_PART_B_12345 = "right";
|
||||||
|
const interpolatedKey = ["$", "{TEST_INTERPOLATED_PART_A_12345}_$", "{TEST_INTERPOLATED_PART_B_12345}"].join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey(interpolatedKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("left_right");
|
||||||
|
} finally {
|
||||||
|
if (originalPartA === undefined) {
|
||||||
|
delete process.env.TEST_INTERPOLATED_PART_A_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_INTERPOLATED_PART_A_12345 = originalPartA;
|
||||||
|
}
|
||||||
|
if (originalPartB === undefined) {
|
||||||
|
delete process.env.TEST_INTERPOLATED_PART_B_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_INTERPOLATED_PART_B_12345 = originalPartB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey with $$ prefix escapes a leading dollar", async () => {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey("$$TEST_API_KEY_12345"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("$TEST_API_KEY_12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => {
|
||||||
|
const originalEnv = process.env.TEST_API_KEY_12345;
|
||||||
|
process.env.TEST_API_KEY_12345 = "env-api-key-value";
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey("$!literal-$TEST_API_KEY_12345"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||||
|
|
||||||
|
expect(apiKey).toBe("!literal-env-api-key-value");
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.TEST_API_KEY_12345;
|
||||||
|
} else {
|
||||||
|
process.env.TEST_API_KEY_12345 = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain apiKey is used directly even when it matches an env var", async () => {
|
||||||
const originalEnv = process.env.TEST_API_KEY_12345;
|
const originalEnv = process.env.TEST_API_KEY_12345;
|
||||||
process.env.TEST_API_KEY_12345 = "env-api-key-value";
|
process.env.TEST_API_KEY_12345 = "env-api-key-value";
|
||||||
|
|
||||||
@@ -1199,7 +1337,7 @@ describe("ModelRegistry", () => {
|
|||||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||||
|
|
||||||
expect(apiKey).toBe("env-api-key-value");
|
expect(apiKey).toBe("TEST_API_KEY_12345");
|
||||||
} finally {
|
} finally {
|
||||||
if (originalEnv === undefined) {
|
if (originalEnv === undefined) {
|
||||||
delete process.env.TEST_API_KEY_12345;
|
delete process.env.TEST_API_KEY_12345;
|
||||||
@@ -1318,7 +1456,7 @@ describe("ModelRegistry", () => {
|
|||||||
process.env[envVarName] = "status-test-key";
|
process.env[envVarName] = "status-test-key";
|
||||||
|
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
"custom-provider": providerWithApiKey(envVarName),
|
"custom-provider": providerWithApiKey(`$${envVarName}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
@@ -1337,6 +1475,41 @@ describe("ModelRegistry", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("provider auth status reports interpolated apiKey environment variables", () => {
|
||||||
|
const envVarNameA = "TEST_API_KEY_STATUS_PART_A_98765";
|
||||||
|
const envVarNameB = "TEST_API_KEY_STATUS_PART_B_98765";
|
||||||
|
const originalEnvA = process.env[envVarNameA];
|
||||||
|
const originalEnvB = process.env[envVarNameB];
|
||||||
|
process.env[envVarNameA] = "left";
|
||||||
|
process.env[envVarNameB] = "right";
|
||||||
|
const interpolatedKey = ["$", "{", envVarNameA, "}_$", "{", envVarNameB, "}"].join("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey(interpolatedKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
|
||||||
|
expect(registry.getProviderAuthStatus("custom-provider")).toEqual({
|
||||||
|
configured: true,
|
||||||
|
source: "environment",
|
||||||
|
label: `${envVarNameA}, ${envVarNameB}`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (originalEnvA === undefined) {
|
||||||
|
delete process.env[envVarNameA];
|
||||||
|
} else {
|
||||||
|
process.env[envVarNameA] = originalEnvA;
|
||||||
|
}
|
||||||
|
if (originalEnvB === undefined) {
|
||||||
|
delete process.env[envVarNameB];
|
||||||
|
} else {
|
||||||
|
process.env[envVarNameB] = originalEnvB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("provider auth status reports non-env apiKey values from models.json as a config key", () => {
|
test("provider auth status reports non-env apiKey values from models.json as a config key", () => {
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
"custom-provider": providerWithApiKey("literal_api_key_value"),
|
"custom-provider": providerWithApiKey("literal_api_key_value"),
|
||||||
@@ -1350,6 +1523,29 @@ describe("ModelRegistry", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("missing explicit env apiKey keeps provider unavailable", () => {
|
||||||
|
const envVarName = "TEST_API_KEY_MISSING_TEST_98765";
|
||||||
|
const originalEnv = process.env[envVarName];
|
||||||
|
delete process.env[envVarName];
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRawModelsJson({
|
||||||
|
"custom-provider": providerWithApiKey(`$${envVarName}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
|
||||||
|
expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ configured: false });
|
||||||
|
expect(registry.getAvailable().some((model) => model.provider === "custom-provider")).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env[envVarName];
|
||||||
|
} else {
|
||||||
|
process.env[envVarName] = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("provider auth status reports command apiKey values from models.json without executing them", () => {
|
test("provider auth status reports command apiKey values from models.json without executing them", () => {
|
||||||
const counterFile = join(tempDir, "status-counter");
|
const counterFile = join(tempDir, "status-counter");
|
||||||
writeFileSync(counterFile, "0");
|
writeFileSync(counterFile, "0");
|
||||||
@@ -1376,7 +1572,7 @@ describe("ModelRegistry", () => {
|
|||||||
process.env[envVarName] = "first-value";
|
process.env[envVarName] = "first-value";
|
||||||
|
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
"custom-provider": providerWithApiKey(envVarName),
|
"custom-provider": providerWithApiKey(`$${envVarName}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user