Skip to content

Commit 201de86

Browse files
Merge pull request #391 from corbitsdev/cl-5680-providercatalogentry-and-providersettings-are-parallel-type
Derive ProviderCatalogEntry from ProviderSettings instead of duplicating its field list
2 parents 2f6dbd6 + 65d3629 commit 201de86

2 files changed

Lines changed: 84 additions & 27 deletions

File tree

src/config.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

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

1011
function assertConfigured(config: Config | UnconfiguredConfig): asserts config is Config {
1112
if (config.configured === false) {
@@ -648,6 +649,74 @@ describe("buildProviderCatalog", () => {
648649
});
649650
expect(restOut).toEqual(restExisting);
650651
});
652+
653+
test("round-trips every ProviderSettings field a catalog entry can carry through buildProviderCatalog and back", () => {
654+
// ProviderCatalogEntry is defined as Omit<ProviderSettings, "name" | "contextWindow">.
655+
// This exercises every field that relationship carries over, so a field
656+
// added to ProviderSettings and forgotten in the two conversion sites
657+
// below fails here instead of being silently dropped at runtime.
658+
// `anthropic` and `opencodeGo` are exercised separately below: both are
659+
// protocol markers that also normalize `baseURL` in buildProviderCatalog,
660+
// so a provider combining them with an arbitrary baseURL isn't a real
661+
// round trip (the healing logic rewrites baseURL by design).
662+
const provider: Settings["providers"][string] = {
663+
baseURL: "https://fp/v1",
664+
apiKey: "fp-key",
665+
models: ["fp-large"],
666+
defaultModel: "fp-large",
667+
free: true,
668+
keyless: true,
669+
bifrostVirtualKey: true,
670+
};
671+
const settings: Settings = { providers: { fp: provider } };
672+
const catalog = buildProviderCatalog(settings, {
673+
providerName: "fp",
674+
baseURL: provider.baseURL,
675+
apiKey: "fp-key",
676+
model: "fp-large",
677+
} as ResolvedProvider);
678+
const entry = catalog.find((c) => c.name === "fp")!;
679+
const roundTripped = { fp: catalogEntryAsProviderSettings(entry) };
680+
expect(roundTripped).toEqual({ fp: provider });
681+
});
682+
683+
test("round-trips the anthropic protocol marker", () => {
684+
const provider: Settings["providers"][string] = {
685+
baseURL: "https://api.anthropic.com/v1",
686+
apiKey: "an-key",
687+
models: ["claude"],
688+
anthropic: true,
689+
};
690+
const settings: Settings = { providers: { an: provider } };
691+
const catalog = buildProviderCatalog(settings, {
692+
providerName: "an",
693+
baseURL: provider.baseURL,
694+
apiKey: "an-key",
695+
model: "claude",
696+
} as ResolvedProvider);
697+
const entry = catalog.find((c) => c.name === "an")!;
698+
const roundTripped = { an: catalogEntryAsProviderSettings(entry) };
699+
expect(roundTripped).toEqual({ an: provider });
700+
});
701+
702+
test("round-trips the opencodeGo protocol marker", () => {
703+
const provider: Settings["providers"][string] = {
704+
baseURL: OPENCODE_GO_BASE_URL,
705+
apiKey: "go-key",
706+
models: ["go-model"],
707+
opencodeGo: true,
708+
};
709+
const settings: Settings = { providers: { go: provider } };
710+
const catalog = buildProviderCatalog(settings, {
711+
providerName: "go",
712+
baseURL: provider.baseURL,
713+
apiKey: "go-key",
714+
model: "go-model",
715+
} as ResolvedProvider);
716+
const entry = catalog.find((c) => c.name === "go")!;
717+
const roundTripped = { go: catalogEntryAsProviderSettings(entry) };
718+
expect(roundTripped).toEqual({ go: provider });
719+
});
651720
});
652721

653722
describe("mergeProviderIntoSettings", () => {

src/config/index.ts

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -91,42 +91,30 @@ export function buildOpenAISource(fields: {
9191

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

132120
// Build the InferenceSource for a Codex OAuth profile. Routes to the

0 commit comments

Comments
 (0)