Detailed changes
@@ -66,6 +66,8 @@ Config uses TOML, validated against TypeBox schema (`src/config/schema.ts`).
Model strings use `provider:model` format. `custom:name` prefix looks up custom model definitions from config's `[custom_models]` section. Built-in providers delegate to `@mariozechner/pi-ai`.
+API key resolution for custom models uses `resolveConfigValue()` from `src/util/env.ts`, which supports bare env var names, `$VAR` / `${VAR}` references, and `!shell-command` execution. Built-in providers fall back to `pi-ai`'s `getEnvApiKey()` (e.g. `ANTHROPIC_API_KEY`).
+
### Error Handling
Custom error classes in `src/util/errors.ts` extend `RumiloError` with error codes:
@@ -39,6 +39,7 @@ You can define custom OpenAI-compatible endpoints like Ollama, vLLM, or self-hos
provider = "ollama"
api = "openai-completions"
base_url = "http://localhost:11434/v1"
+api_key = "ollama"
id = "ollama/llama3"
name = "Llama 3 (Ollama)"
reasoning = false
@@ -60,6 +61,7 @@ rumilo repo -u <uri> "query" --model custom:ollama
- `provider`: Provider identifier (e.g., "ollama", "custom")
- `api`: API type - typically "openai-completions"
- `base_url`: API endpoint URL
+- `api_key`: API key (see value resolution below)
- `id`: Unique model identifier
- `name`: Human-readable display name
- `reasoning`: Whether the model supports thinking/reasoning
@@ -67,6 +69,24 @@ rumilo repo -u <uri> "query" --model custom:ollama
- `cost`: Cost per million tokens (can use 0 for local models)
- `context_window`: Maximum context size in tokens
- `max_tokens`: Maximum output tokens
+- `headers`: Optional custom HTTP headers (values support same resolution as `api_key`)
+
+#### Value Resolution
+
+The `api_key` and `headers` fields support three formats, following [pi-coding-agent conventions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md):
+
+- **Environment variable name:** bare name is checked as env var, then used as literal
+ ```toml
+ api_key = "MY_API_KEY" # resolves process.env.MY_API_KEY, or literal "MY_API_KEY"
+ ```
+- **Env var reference:** explicit `$VAR` or `${VAR}`
+ ```toml
+ api_key = "$MY_API_KEY" # always resolves from env
+ ```
+- **Shell command:** `!command` executes and uses stdout
+ ```toml
+ api_key = "!security find-generic-password -ws 'my-api'"
+ ```
#### Compatibility Flags (Optional)
@@ -5,8 +5,8 @@
"": {
"name": "rumilo",
"dependencies": {
- "@mariozechner/pi-agent": "^0.9.0",
- "@mariozechner/pi-ai": "^0.6.1",
+ "@mariozechner/pi-agent-core": "/home/exedev/repos/personal/pi-mono/packages/agent",
+ "@mariozechner/pi-ai": "^0.52.8",
"@sinclair/typebox": "^0.32.14",
"@tabstack/sdk": "^2.1.0",
"kagi-ken": "github:czottmann/kagi-ken#1.2.0",
@@ -21,21 +21,145 @@
},
},
"packages": {
- "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.61.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-GnlOXrPxow0uoaVB3DGNh9EJBU1MyagCBCLpU+bwDVlj/oOPYIwoiasMWlykkfYcQOrDP2x/zHnRD0xN7PeZPw=="],
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="],
+
+ "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
+
+ "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
+
+ "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
+
+ "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
+
+ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
+
@@ -15,8 +15,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@mariozechner/pi-ai": "^0.6.1",
- "@mariozechner/pi-agent": "^0.9.0",
+ "@mariozechner/pi-agent-core": "/home/exedev/repos/personal/pi-mono/packages/agent",
+ "@mariozechner/pi-ai": "^0.52.8",
"@sinclair/typebox": "^0.32.14",
"@tabstack/sdk": "^2.1.0",
"kagi-ken": "github:czottmann/kagi-ken#1.2.0",
@@ -4,6 +4,7 @@ import {
type RumiloConfig,
} from "../config/schema.js";
import { ConfigError } from "../util/errors.js";
+import { resolveHeaders } from "../util/env.js";
export function resolveModel(
modelString: string,
@@ -77,8 +78,9 @@ function buildCustomModel(config: CustomModelConfig): Model<any> {
maxTokens: config.max_tokens,
};
- if (config.headers) {
- model.headers = config.headers;
+ const resolvedHeaders = resolveHeaders(config.headers);
+ if (resolvedHeaders) {
+ model.headers = resolvedHeaders;
}
if (config.compat) {
@@ -1,9 +1,9 @@
-import { Agent, ProviderTransport, type AgentEvent } from "@mariozechner/pi-agent";
-import { getApiKey, type AgentTool, type AssistantMessage } from "@mariozechner/pi-ai";
+import { Agent, type AgentEvent, type AgentTool } from "@mariozechner/pi-agent-core";
+import { getEnvApiKey, type AssistantMessage } from "@mariozechner/pi-ai";
import type { RumiloConfig } from "../config/schema.js";
import { resolveModel } from "./model-resolver.js";
import { AgentError } from "../util/errors.js";
-import { expandEnvVars } from "../util/env.js";
+import { resolveConfigValue } from "../util/env.js";
export interface AgentRunOptions {
model: string;
@@ -19,34 +19,25 @@ export interface AgentRunResult {
}
/**
- * Build a getApiKey callback that checks custom model headers first,
- * then falls back to pi-ai's built-in env-var lookup.
+ * Build a getApiKey callback for the Agent.
*
- * Custom models may specify `Authorization: "Bearer $SOME_ENV_VAR"` in their
- * headers config. We extract the bearer token and expand env var references
- * so the value reaches the OpenAI-compatible SDK as a real API key.
+ * Resolution order:
+ * 1. Custom model config β if a custom model for this provider defines an
+ * `apiKey` field, resolve it via `resolveConfigValue` (supports env var
+ * names, `$VAR` references, and `!shell` commands).
+ * 2. pi-aiβs built-in env-var lookup (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.).
*/
export function buildGetApiKey(config: RumiloConfig): (provider: string) => string | undefined {
return (provider: string) => {
if (config.custom_models) {
for (const model of Object.values(config.custom_models)) {
- if (model.provider === provider && model.headers) {
- const authHeader = model.headers["Authorization"] ?? model.headers["authorization"];
- if (authHeader) {
- const expanded = expandEnvVars(authHeader);
- const match = expanded.match(/^Bearer\s+(.+)$/i);
- if (match) {
- return match[1];
- }
- return expanded;
- }
+ if (model.provider === provider && model.api_key) {
+ return resolveConfigValue(model.api_key);
}
}
}
- // Fall back to pi-ai's built-in env var resolution
- // (handles anthropic β ANTHROPIC_API_KEY, openai β OPENAI_API_KEY, etc.)
- return getApiKey(provider);
+ return getEnvApiKey(provider);
};
}
@@ -57,9 +48,7 @@ export async function runAgent(query: string, options: AgentRunOptions): Promise
model: resolveModel(options.model, options.config),
tools: options.tools,
},
- transport: new ProviderTransport({
- getApiKey: buildGetApiKey(options.config),
- }),
+ getApiKey: buildGetApiKey(options.config),
});
if (options.onEvent) {
@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import { relative } from "node:path";
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
import { ToolInputError } from "../../util/errors.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import simpleGit from "simple-git";
import { ToolInputError } from "../../../util/errors.js";
import { formatSize, truncateHead } from "../../../util/truncate.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import simpleGit from "simple-git";
import { ToolInputError } from "../../../util/errors.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import simpleGit from "simple-git";
import { ToolInputError } from "../../../util/errors.js";
import { formatSize, truncateHead } from "../../../util/truncate.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import simpleGit from "simple-git";
import { ToolInputError } from "../../../util/errors.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import simpleGit from "simple-git";
import { formatSize, truncateHead } from "../../../util/truncate.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import simpleGit from "simple-git";
import { ToolInputError } from "../../../util/errors.js";
import { formatSize, truncateHead } from "../../../util/truncate.js";
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline";
import { readFileSync, statSync } from "node:fs";
import { relative, basename } from "node:path";
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
import {
DEFAULT_MAX_BYTES,
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
export type ToolFactory = (workspacePath: string) => AgentTool<any>;
@@ -1,7 +1,7 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
@@ -1,6 +1,6 @@
import { readFile, stat } from "node:fs/promises";
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveReadPath, ensureWorkspacePath } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../util/truncate.js";
import { ToolInputError } from "../../util/errors.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import Tabstack from "@tabstack/sdk";
import { FetchError, ToolInputError } from "../../util/errors.js";
@@ -1,5 +1,5 @@
import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
import { search } from "kagi-ken";
import { FetchError, ToolInputError } from "../../util/errors.js";
@@ -1,4 +1,4 @@
-import type { AgentEvent } from "@mariozechner/pi-agent";
+import type { AgentEvent } from "@mariozechner/pi-agent-core";
const MAX_OUTPUT_LINES = 20;
@@ -16,6 +16,7 @@ const CustomModelSchema = Type.Object({
}),
context_window: Type.Number(),
max_tokens: Type.Number(),
+ api_key: Type.Optional(Type.String()),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(
Type.Object({
@@ -1,10 +1,75 @@
+import { execSync } from "node:child_process";
+
/**
- * Expand `$VAR` and `${VAR}` references to their environment variable values.
- * Returns the string unchanged if it contains no references.
+ * Resolve a configuration value (API key, header value, etc.) to a concrete string.
+ *
+ * Resolution order, following pi-coding-agent's convention:
+ * - `"!command"` β executes the rest as a shell command, uses trimmed stdout
+ * - `"$VAR"` or `"${VAR}"` β treats as an env var reference (with sigil)
+ * - Otherwise checks `process.env[value]` β bare name is tried as env var
+ * - If no env var matches, the string is used as a literal value
+ *
+ * Returns `undefined` only when a shell command fails or produces empty output.
+ */
+export function resolveConfigValue(value: string): string | undefined {
+ if (value.startsWith("!")) {
+ return executeShellCommand(value.slice(1));
+ }
+
+ // Explicit $VAR or ${VAR} reference
+ const envRef = value.match(/^\$\{(.+)\}$|^\$([A-Za-z_][A-Za-z0-9_]*)$/);
+ if (envRef) {
+ const name = envRef[1] ?? envRef[2]!;
+ return process.env[name] ?? undefined;
+ }
+
+ // Bare name β check as env var first, then use as literal
+ const envValue = process.env[value];
+ return envValue || value;
+}
+
+/**
+ * Expand `$VAR` and `${VAR}` references embedded within a larger string.
+ * Unlike `resolveConfigValue`, this handles mixed literal + env-var strings
+ * like `"Bearer $API_KEY"`.
*/
export function expandEnvVars(value: string): string {
- return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, bare) => {
- const name = braced ?? bare;
- return process.env[name] ?? "";
- });
+ return value.replace(
+ /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
+ (_, braced, bare) => {
+ const name = braced ?? bare;
+ return process.env[name] ?? "";
+ },
+ );
+}
+
+/**
+ * Resolve all values in a headers record using `resolveConfigValue`.
+ * Drops entries whose values resolve to `undefined`.
+ */
+export function resolveHeaders(
+ headers: Record<string, string> | undefined,
+): Record<string, string> | undefined {
+ if (!headers) return undefined;
+ const resolved: Record<string, string> = {};
+ for (const [key, value] of Object.entries(headers)) {
+ const resolvedValue = resolveConfigValue(value);
+ if (resolvedValue) {
+ resolved[key] = resolvedValue;
+ }
+ }
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
+}
+
+function executeShellCommand(command: string): string | undefined {
+ try {
+ const output = execSync(command, {
+ encoding: "utf-8",
+ timeout: 10_000,
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ return output.trim() || undefined;
+ } catch {
+ return undefined;
+ }
}
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { AgentError } from "../src/util/errors.js";
-import { expandEnvVars } from "../src/util/env.js";
+import { expandEnvVars, resolveConfigValue, resolveHeaders } from "../src/util/env.js";
import { buildGetApiKey } from "../src/agent/runner.js";
import type { RumiloConfig } from "../src/config/schema.js";
@@ -10,6 +10,27 @@ const stubConfig: RumiloConfig = {
repo: { model: "anthropic:test", default_depth: 1, blob_limit: "5m" },
};
+function customModel(provider: string, apiKey?: string): RumiloConfig {
+ return {
+ ...stubConfig,
+ custom_models: {
+ mymodel: {
+ id: "m1",
+ name: "M1",
+ api: "openai-completions" as any,
+ provider,
+ base_url: "http://localhost:8000/v1",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0 },
+ context_window: 8192,
+ max_tokens: 4096,
+ ...(apiKey ? { api_key: apiKey } : {}),
+ },
+ },
+ };
+}
+
describe("AgentError", () => {
test("has correct name, code, and inherits from Error", () => {
const err = new AgentError("boom");
@@ -20,6 +41,48 @@ describe("AgentError", () => {
});
});
+describe("resolveConfigValue", () => {
+ const saved: Record<string, string | undefined> = {};
+
+ beforeAll(() => {
+ saved["RUMILO_TEST_KEY"] = process.env["RUMILO_TEST_KEY"];
+ process.env["RUMILO_TEST_KEY"] = "resolved-value";
+ });
+
+ afterAll(() => {
+ if (saved["RUMILO_TEST_KEY"] === undefined) delete process.env["RUMILO_TEST_KEY"];
+ else process.env["RUMILO_TEST_KEY"] = saved["RUMILO_TEST_KEY"];
+ });
+
+ test("resolves bare env var name", () => {
+ expect(resolveConfigValue("RUMILO_TEST_KEY")).toBe("resolved-value");
+ });
+
+ test("resolves $VAR reference", () => {
+ expect(resolveConfigValue("$RUMILO_TEST_KEY")).toBe("resolved-value");
+ });
+
+ test("resolves ${VAR} reference", () => {
+ expect(resolveConfigValue("${RUMILO_TEST_KEY}")).toBe("resolved-value");
+ });
+
+ test("treats unknown name as literal", () => {
+ expect(resolveConfigValue("sk-literal-key-12345")).toBe("sk-literal-key-12345");
+ });
+
+ test("returns undefined for unknown $VAR", () => {
+ expect(resolveConfigValue("$RUMILO_NONEXISTENT_XYZ")).toBeUndefined();
+ });
+
+ test("executes shell commands with ! prefix", () => {
+ expect(resolveConfigValue("!echo hello")).toBe("hello");
+ });
+
+ test("returns undefined for failing shell command", () => {
+ expect(resolveConfigValue("!false")).toBeUndefined();
+ });
+});
+
describe("expandEnvVars", () => {
const saved: Record<string, string | undefined> = {};
@@ -58,6 +121,34 @@ describe("expandEnvVars", () => {
});
});
+describe("resolveHeaders", () => {
+ const saved: Record<string, string | undefined> = {};
+
+ beforeAll(() => {
+ saved["RUMILO_HDR_KEY"] = process.env["RUMILO_HDR_KEY"];
+ process.env["RUMILO_HDR_KEY"] = "hdr-value";
+ });
+
+ afterAll(() => {
+ if (saved["RUMILO_HDR_KEY"] === undefined) delete process.env["RUMILO_HDR_KEY"];
+ else process.env["RUMILO_HDR_KEY"] = saved["RUMILO_HDR_KEY"];
+ });
+
+ test("returns undefined for undefined input", () => {
+ expect(resolveHeaders(undefined)).toBeUndefined();
+ });
+
+ test("resolves header values via resolveConfigValue", () => {
+ const result = resolveHeaders({ "X-Key": "RUMILO_HDR_KEY" });
+ expect(result).toEqual({ "X-Key": "hdr-value" });
+ });
+
+ test("drops entries that resolve to undefined", () => {
+ const result = resolveHeaders({ "X-Key": "$RUMILO_NONEXISTENT_XYZ" });
+ expect(result).toBeUndefined();
+ });
+});
+
describe("buildGetApiKey", () => {
const saved: Record<string, string | undefined> = {};
@@ -85,73 +176,39 @@ describe("buildGetApiKey", () => {
expect(getKey("unknown-provider")).toBeUndefined();
});
- test("extracts bearer token from custom model Authorization header", () => {
- const config: RumiloConfig = {
- ...stubConfig,
- custom_models: {
- mymodel: {
- id: "m1",
- name: "M1",
- api: "openai-completions" as any,
- provider: "myprovider",
- base_url: "http://localhost:8000/v1",
- reasoning: false,
- input: ["text"],
- cost: { input: 0, output: 0 },
- context_window: 8192,
- max_tokens: 4096,
- headers: { Authorization: "Bearer sk-literal-key" },
- },
- },
- };
+ test("resolves literal api_key from custom model", () => {
+ const config = customModel("myprovider", "sk-literal-key");
const getKey = buildGetApiKey(config);
expect(getKey("myprovider")).toBe("sk-literal-key");
});
- test("expands env vars in Authorization header", () => {
- const config: RumiloConfig = {
- ...stubConfig,
- custom_models: {
- mymodel: {
- id: "m1",
- name: "M1",
- api: "openai-completions" as any,
- provider: "myprovider",
- base_url: "http://localhost:8000/v1",
- reasoning: false,
- input: ["text"],
- cost: { input: 0, output: 0 },
- context_window: 8192,
- max_tokens: 4096,
- headers: { Authorization: "Bearer $CUSTOM_KEY" },
- },
- },
- };
+ test("resolves api_key via env var name", () => {
+ const config = customModel("myprovider", "CUSTOM_KEY");
const getKey = buildGetApiKey(config);
expect(getKey("myprovider")).toBe("sk-custom-test");
});
+ test("resolves api_key via $VAR reference", () => {
+ const config = customModel("myprovider", "$CUSTOM_KEY");
+ const getKey = buildGetApiKey(config);
+ expect(getKey("myprovider")).toBe("sk-custom-test");
+ });
+
+ test("resolves api_key via shell command", () => {
+ const config = customModel("myprovider", "!echo shell-key");
+ const getKey = buildGetApiKey(config);
+ expect(getKey("myprovider")).toBe("shell-key");
+ });
+
test("custom model provider doesn't shadow built-in provider lookup", () => {
- const config: RumiloConfig = {
- ...stubConfig,
- custom_models: {
- mymodel: {
- id: "m1",
- name: "M1",
- api: "openai-completions" as any,
- provider: "other-provider",
- base_url: "http://localhost:8000/v1",
- reasoning: false,
- input: ["text"],
- cost: { input: 0, output: 0 },
- context_window: 8192,
- max_tokens: 4096,
- headers: { Authorization: "Bearer sk-other" },
- },
- },
- };
+ const config = customModel("other-provider", "sk-other");
+ const getKey = buildGetApiKey(config);
+ expect(getKey("anthropic")).toBe("sk-ant-test");
+ });
+
+ test("falls back to env var lookup when custom model has no api_key", () => {
+ const config = customModel("anthropic");
const getKey = buildGetApiKey(config);
- // anthropic should still resolve from env, not from the custom model
expect(getKey("anthropic")).toBe("sk-ant-test");
});
});