diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 5244f51cc84..e16f38cbde7 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -47,6 +47,10 @@ export const layer = Layer.effect( input.model ? new ModelSelectionError({ message: error.message }) : new UnavailableError({ message: error.message, service: error.providerID }), + "SessionRunnerModel.UnresolvedProviderVariablesError": (error) => + input.model + ? new ModelSelectionError({ message: error.message }) + : new UnavailableError({ message: error.message, service: error.providerID }), }), ) if (!resolved) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 0f9424fdb96..346c2402cfd 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -46,7 +46,24 @@ export class UnsupportedPackageError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.UnresolvedProviderVariablesError", + { + providerID: Provider.ID, + modelID: ID, + variables: Schema.Array(Schema.String), + }, +) { + override get message() { + return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.variables.join(", ")} ${this.variables.length === 1 ? "is" : "are"} required to resolve the provider endpoint` + } +} + +export type Error = + | VariantUnavailableError + | UnsupportedPackageError + | UnresolvedProviderVariablesError + | Integration.AuthorizationError export interface Resolved { /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ @@ -141,12 +158,17 @@ export const fromCatalogModel = ( model: Info, credential?: Credential.Value, dependencies?: Dependencies, -): Effect.Effect => { - const resolved = produce(model, (draft) => { - if (draft.settings?.apiKey === "") delete draft.settings.apiKey - if (credential?.type === "key" && credential.metadata !== undefined) - draft.body = Provider.mergeOverlay(draft.body, credential.metadata) - }) +): Effect.Effect => { + const prepared = prepareRuntimeModel(model, credential) + if (prepared.unresolved.length > 0) + return Effect.fail( + new UnresolvedProviderVariablesError({ + providerID: model.providerID, + modelID: model.id, + variables: prepared.unresolved, + }), + ) + const resolved = prepared.model const packageName = Provider.packageName(resolved.package) const key = apiKey(resolved, credential) const configuration = credential?.type === "key" ? credential.configuration : undefined @@ -226,6 +248,26 @@ export const fromCatalogModel = ( }) } +function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) { + const prepared = produce(model, (draft) => { + if (draft.settings?.apiKey === "") delete draft.settings.apiKey + if (credential?.type === "key" && credential.metadata !== undefined) + draft.body = Provider.mergeOverlay(draft.body, credential.metadata) + if (typeof draft.settings?.baseURL !== "string") return + draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => { + return process.env[name] ?? placeholder + }) + }) + const baseURL = prepared.settings?.baseURL + const unresolved = + typeof baseURL === "string" + ? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter( + (name, index, names) => names.indexOf(name) === index, + ) + : [] + return { model: prepared, unresolved } +} + const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { if (!credential) return {} if (credential.type === "key") return { apiKey: credential.key } diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index d53feca8fae..6379a916577 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -59,36 +59,8 @@ function environmentNames(provider: ModelsDev.Snapshot) { } function snapshots(data: readonly ModelsDev.Snapshot[]) { - return ( - structuredClone(data) - // These deprecated aliases are replaced by the canonical Azure and Google Vertex providers. - .filter( - (provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic", - ) - .map((provider) => { - const environment = new Set(provider.environment) - return { - ...provider, - info: { - ...provider.info, - ...(provider.info.settings ? { settings: resolveEnvironment(provider.info.settings, environment) } : {}), - }, - models: provider.models.map((model) => ({ - ...model, - ...(model.settings ? { settings: resolveEnvironment(model.settings, environment) } : {}), - })), - } - }) + return structuredClone(data).filter( + // These deprecated aliases are replaced by the canonical Azure and Google Vertex providers. + (provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic", ) } - -function resolveEnvironment(settings: Readonly>, environment: Set) { - if (typeof settings.baseURL !== "string") return settings - return { - ...settings, - baseURL: settings.baseURL.replace(/\$\{([^}]+)\}/g, (value, name: string) => { - if (!environment.has(name)) return value - return process.env[name] ?? value - }), - } -} diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index f3e6b9263a7..90dd5f52f73 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -34,6 +34,8 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError export type VariantUnavailableError = ModelResolver.VariantUnavailableError export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError +export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError +export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error export type Resolved = ModelResolver.Resolved diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 0a57bb296db..a13e013e8a3 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -52,7 +52,8 @@ export function toSessionError(cause: unknown): SessionError.Error { cause instanceof SessionRunnerModel.ModelNotSelectedError || cause instanceof SessionRunnerModel.ModelUnavailableError || cause instanceof SessionRunnerModel.VariantUnavailableError || - cause instanceof SessionRunnerModel.UnsupportedPackageError + cause instanceof SessionRunnerModel.UnsupportedPackageError || + cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError ) return { type: "provider.no-route", message: cause.message } if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message } diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 2c3a3d6af1a..7d04516f8cc 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -42,6 +42,27 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => limit: options.limit ?? { context: 100, output: 20 }, }) +function withEnv(variables: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]])) + Object.entries(variables).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + describe("ModelResolver", () => { it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () => Effect.gen(function* () { @@ -230,6 +251,42 @@ describe("ModelResolver", () => { }), ) + it.effect("resolves provider URLs from environment without mutating the catalog model", () => + withEnv({ ACME_HOST: "api.acme.test" }, () => + Effect.gen(function* () { + const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), { + settings: { baseURL: "https://${ACME_HOST}/v1" }, + }) + const resolved = yield* ModelResolver.fromCatalogModel(catalog) + + expect(resolved.route.endpoint.baseURL).toBe("https://api.acme.test/v1") + expect(catalog.settings?.baseURL).toBe("https://${ACME_HOST}/v1") + }), + ), + ) + + it.effect("rejects unresolved provider URL variables before route construction", () => + withEnv({ REQUIRED_HOST: undefined }, () => + Effect.gen(function* () { + const failure = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai-compatible"), { + settings: { baseURL: "https://${REQUIRED_HOST}/${REQUIRED_PATH}/v1" }, + }), + ).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.UnresolvedProviderVariablesError", + providerID: "test-provider", + modelID: "test-model", + variables: ["REQUIRED_HOST", "REQUIRED_PATH"], + }) + expect(failure.message).toBe( + "Cannot initialize test-provider/test-model: REQUIRED_HOST, REQUIRED_PATH are required to resolve the provider endpoint", + ) + }), + ), + ) + it.effect("overlays selected OpenAI variant settings and bodies", () => Effect.gen(function* () { const catalog = model(Provider.aisdk("@ai-sdk/openai"), { diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 146c26be6f6..9fe3d7f761a 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -242,7 +242,7 @@ describe("ModelsDevPlugin", () => { }).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))), ) - it.effect("resolves declared environment variables in provider and model URLs", () => + it.effect("preserves provider and model URL templates in the catalog", () => withEnv( { ACME_HOST: "api.acme.test", @@ -298,10 +298,10 @@ describe("ModelsDevPlugin", () => { ) expect((yield* catalog.provider.get(providerID))?.settings?.baseURL).toBe( - "https://api.acme.test/${UNDECLARED_HOST}/v1", + "https://${ACME_HOST}/${UNDECLARED_HOST}/v1", ) expect((yield* catalog.model.get(providerID, modelID))?.settings?.baseURL).toBe( - "https://api.acme.test/${ACME_MODEL_PATH}/v1", + "https://${ACME_HOST}/${ACME_MODEL_PATH}/v1", ) }), ), diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 72f8ebf85ed..625427d65cd 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -19,6 +19,9 @@ import { HttpResponseDetails, } from "@opencode-ai/ai" import { Permission } from "@opencode-ai/core/permission" +import { ID } from "@opencode-ai/core/model" +import { ModelResolver } from "@opencode-ai/core/model-resolver" +import { Provider } from "@opencode-ai/core/provider" import { Tool } from "@opencode-ai/schema/tool" import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" @@ -91,6 +94,19 @@ describe("toSessionError", () => { }) }) + test("preserves unresolved provider endpoint errors", () => { + const error = new ModelResolver.UnresolvedProviderVariablesError({ + providerID: Provider.ID.make("cloudflare-workers-ai"), + modelID: ID.make("model"), + variables: ["CLOUDFLARE_ACCOUNT_ID"], + }) + expect(toSessionError(error)).toEqual({ + type: "provider.no-route", + message: + "Cannot initialize cloudflare-workers-ai/model: CLOUDFLARE_ACCOUNT_ID is required to resolve the provider endpoint", + }) + }) + test("retries only rate limits, provider-internal failures, and transport failures", () => { const eligible = [ llm(new RateLimitReason({ message: "rate" })),