Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { buildBifrostSource, buildOpenAISource, buildProviderCatalog, KEYLESS_API_KEY, loadConfig, providerCatalogToSettings, runtimeSettingsWithCatalog, SOURCE_MAX_TOKENS } from "./config/index.js";
import { buildBifrostSource, buildOpenAISource, buildProviderCatalog, catalogEntryAsProviderSettings, KEYLESS_API_KEY, loadConfig, providerCatalogToSettings, runtimeSettingsWithCatalog, SOURCE_MAX_TOKENS } from "./config/index.js";
import type { Config, UnconfiguredConfig } from "./config/index.js";
import { mergeProviderIntoSettings, type ResolvedProvider, type Settings } from "./config/settings.js";
import { OPENCODE_GO_BASE_URL } from "../packages/opencode-go/src/index.js";

function assertConfigured(config: Config | UnconfiguredConfig): asserts config is Config {
if (config.configured === false) {
Expand Down Expand Up @@ -648,6 +649,74 @@ describe("buildProviderCatalog", () => {
});
expect(restOut).toEqual(restExisting);
});

test("round-trips every ProviderSettings field a catalog entry can carry through buildProviderCatalog and back", () => {
// ProviderCatalogEntry is defined as Omit<ProviderSettings, "name" | "contextWindow">.
// This exercises every field that relationship carries over, so a field
// added to ProviderSettings and forgotten in the two conversion sites
// below fails here instead of being silently dropped at runtime.
// `anthropic` and `opencodeGo` are exercised separately below: both are
// protocol markers that also normalize `baseURL` in buildProviderCatalog,
// so a provider combining them with an arbitrary baseURL isn't a real
// round trip (the healing logic rewrites baseURL by design).
const provider: Settings["providers"][string] = {
baseURL: "https://fp/v1",
apiKey: "fp-key",
models: ["fp-large"],
defaultModel: "fp-large",
free: true,
keyless: true,
bifrostVirtualKey: true,
};
const settings: Settings = { providers: { fp: provider } };
const catalog = buildProviderCatalog(settings, {
providerName: "fp",
baseURL: provider.baseURL,
apiKey: "fp-key",
model: "fp-large",
} as ResolvedProvider);
const entry = catalog.find((c) => c.name === "fp")!;
const roundTripped = { fp: catalogEntryAsProviderSettings(entry) };
expect(roundTripped).toEqual({ fp: provider });
});

test("round-trips the anthropic protocol marker", () => {
const provider: Settings["providers"][string] = {
baseURL: "https://api.anthropic.com/v1",
apiKey: "an-key",
models: ["claude"],
anthropic: true,
};
const settings: Settings = { providers: { an: provider } };
const catalog = buildProviderCatalog(settings, {
providerName: "an",
baseURL: provider.baseURL,
apiKey: "an-key",
model: "claude",
} as ResolvedProvider);
const entry = catalog.find((c) => c.name === "an")!;
const roundTripped = { an: catalogEntryAsProviderSettings(entry) };
expect(roundTripped).toEqual({ an: provider });
});

test("round-trips the opencodeGo protocol marker", () => {
const provider: Settings["providers"][string] = {
baseURL: OPENCODE_GO_BASE_URL,
apiKey: "go-key",
models: ["go-model"],
opencodeGo: true,
};
const settings: Settings = { providers: { go: provider } };
const catalog = buildProviderCatalog(settings, {
providerName: "go",
baseURL: provider.baseURL,
apiKey: "go-key",
model: "go-model",
} as ResolvedProvider);
const entry = catalog.find((c) => c.name === "go")!;
const roundTripped = { go: catalogEntryAsProviderSettings(entry) };
expect(roundTripped).toEqual({ go: provider });
});
});

describe("mergeProviderIntoSettings", () => {
Expand Down
40 changes: 14 additions & 26 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,42 +91,30 @@ export function buildOpenAISource(fields: {

// One configured provider the /agent modal can switch to. Carries credentials
// because live switching builds an InferenceSource from it; the modal only ever
// receives fields needed for provider management, never the key.
export type ProviderCatalogEntry = {
// receives fields needed for provider management, never the key. Derived from
// ProviderSettings (the persisted record) so the field *set* stays tied to it:
// a newly required ProviderSettings field forces every catalog-entry literal
// to supply it. `name` becomes required (every catalog entry is resolved to a
// concrete provider id) and `contextWindow` is dropped (it is a settings-only
// override, never surfaced to the /agent modal). The OAuth-profile markers
// below have no ProviderSettings counterpart because such entries are never
// written to settings.json (their credentials live in the Codex/xAI auth
// stores). Optional fields still need the round-trip test in config.test.ts —
// TS does not flag a missing optional property against an explicitly-typed
// object literal, so forwarding of an optional field can only be caught at
// runtime.
export type ProviderCatalogEntry = Omit<ProviderSettings, "name" | "contextWindow"> & {
name: string;
baseURL: string;
// Absent for keyless providers (see `keyless`). When present, carries the
// secret key the harness injects as a Bearer credential.
apiKey?: string;
models: string[];
defaultModel?: string;
// True for local providers that require no authentication (e.g. Ollama).
// When set, `apiKey` is omitted and resolution skips the key check.
keyless?: boolean;
// Manual override suppressing the status-bar dollar cost for this provider.
free?: boolean;
// Set when this entry is a Codex OAuth profile rather than an API-key
// provider. Holds the profile name; the send path uses it to refresh the
// access token before each turn. Such entries are never written to
// settings.json (their credentials live in the Codex auth store).
// access token before each turn.
codexProfile?: string;
// ChatGPT account id for a Codex profile, sent as the chatgpt-account-id
// header by the Responses adapter. Present only on Codex entries.
codexAccountId?: string;
// Set when this entry is an xAI/Grok OAuth profile. It still routes through
// openai-compatible; the marker only controls token refresh and persistence.
xaiProfile?: string;
// When true this provider is backed by a Bifrost virtual key. Inference
// sources for it are built with provider "bifrost" so the adapter can
// inject the x-bf-vk header (in addition to Authorization). The flag is
// also used to enable /models auto-discovery scoped to the key.
bifrostVirtualKey?: boolean;
// Anthropic Messages API (x-api-key). Used by first-class Anthropic and by
// OpenCode Go models that speak the messages protocol.
anthropic?: boolean;
// OpenCode Go multi-protocol provider. Per-model routing picks
// openai-compatible, openai-responses, or anthropic at source-build time.
opencodeGo?: boolean;
};

// Build the InferenceSource for a Codex OAuth profile. Routes to the
Expand Down
Loading