diff --git a/memory-bank/aiConfig.md b/memory-bank/aiConfig.md index dfca8ca..dd4e2a6 100644 --- a/memory-bank/aiConfig.md +++ b/memory-bank/aiConfig.md @@ -27,12 +27,14 @@ enablement in Posit Assistant lives in the main monorepo's The package has three entrypoints, splitting pure (browser/test-safe) logic from filesystem I/O and vscode-bound wiring: -| Entrypoint | What it exports | External deps? | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `ai-config` | Vocabulary, Zod schemas, inferred types, defaults, the pure resolution helpers (`resolveModels`, `mergeEnforced`), and the config-source contracts | No | -| `ai-config/node` | Re-exports the pure entry plus the three filesystem seams (`loadResolvedProviderCatalog`, `mutateProvidersConfig`, `watchResolvedProviderCatalog`) and path constants | Node FS | -| `ai-config/positron` | Builds a `host`-kind config source from Positron's `authentication.*` VS Code settings (injected via `additionalSources`); the pure `buildAuthenticationFragment` builder is testable without vscode | `vscode` | -| `ai-config/providers.schema.json` | The generated JSON Schema, exported so editors can validate/autocomplete `providers.json` | No | +| Entrypoint | What it exports | External deps? | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `ai-config` | Vocabulary, Zod schemas, inferred types, defaults, the pure resolution helpers (`resolveModels`, `mergeEnforced`), and the config-source contracts | No | +| `ai-config/node` | Re-exports the pure entry plus the three filesystem seams (`loadResolvedProviderCatalog`, `mutateProvidersConfig`, `watchResolvedProviderCatalog`) and path constants | Node FS | +| `ai-config/positron` | Builds a `host`-kind config source from Positron's `authentication.*` VS Code settings (injected via `additionalSources`); the pure `buildAuthenticationFragment` builder is testable without vscode | `vscode` | + +Each provider's `PositronAuthSettingDescriptor` (consumed by `buildAuthenticationFragment`) may carry an optional `normalizeBaseUrl?: (url: string) => string` hook, applied to the raw `baseUrl` setting before it enters the fragment. It's the seam for correcting known-bad values (e.g. a bare API host missing its version segment) without `ai-config` importing `ai-provider-bridge` — the consumer (`packages/positron`) injects the bridge's `normalizeBaseUrlForProvider` when building descriptors; `ai-config` only ever sees an opaque string-to-string function. +| `ai-config/providers.schema.json` | The generated JSON Schema, exported so editors can validate/autocomplete `providers.json` | No | ### Pure entry (`ai-config`) diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index c2f264d..64c6ce5 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -60,6 +60,12 @@ Positron's direct providers are the union of those two sets. Currently: - Mapped auth providers: `anthropic`, `positai`, `openai`, `gemini`, `google-vertex`, `openai-compatible`, `bedrock`, `ms-foundry`, `snowflake-cortex`, `copilot`, `deepseek` - Local providers: `ollama`, `lmstudio` +## Base URLs + +Model clients (`AnthropicClient`, `OpenAIClient`, `GeminiClient`) **trust the base URL they are given** — `params.baseUrl ?? this.baseURL`, used raw, with no chat-time correction. There used to be a `normalizeConfiguredBaseUrl` workaround inside the clients that patched a bare host (e.g. `https://api.anthropic.com`) to its versioned form at request time; that has been removed in favor of fixing the value once, upstream, at the config-read seam. + +The correction policy itself lives in one public helper, `normalizeBaseUrlForProvider(providerId, url)` (`src/base-url.ts`, exported from the root entrypoint): it corrects a bare known host (anthropic/openai/gemini, tolerant of whitespace and trailing slashes) to `host/version`, and returns **any other input byte-for-byte unchanged** — so `result !== url` means precisely "bare-host fix applied." Consumers (Positron's `authentication-source.ts` and `fix-base-url-settings.ts`) use that identity check as their write-back/notification criterion. `normalizeProviderBaseUrl` (used by the model-discovery fetchers) is unrelated: it only fills in a host/version when the URL is unset and trims whitespace/trailing slashes for composition — it does not correct a configured bare host. + ## Credentials `ProviderCredentials` is a discriminated union (`apikey`, `oauth`, `local`, `aws-credentials`, `google-cloud`) produced by `CredentialProvider` implementations. Client factories receive the resolved credential object and use it to authenticate every model-discovery and chat request. diff --git a/packages/ai-config/src/__tests__/positron-source.test.ts b/packages/ai-config/src/__tests__/positron-source.test.ts index 6b51aee..1020245 100644 --- a/packages/ai-config/src/__tests__/positron-source.test.ts +++ b/packages/ai-config/src/__tests__/positron-source.test.ts @@ -142,6 +142,61 @@ describe("buildAuthenticationFragment", () => { }); }); + it("applies the descriptor's normalizeBaseUrl to a set base URL", () => { + const normalizeBaseUrl = (url: string) => + url === "https://bare.example" ? "https://bare.example/v1" : url; + const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl }; + const reader = fakeReader({ baseUrls: { anthropic: "https://bare.example" } }); + const fragment = buildAuthenticationFragment(reader, [descriptor]); + expect(fragment).toEqual({ + providers: { anthropic: { baseUrl: "https://bare.example/v1" } }, + }); + }); + + it("passes a value the normalizer leaves alone through unchanged", () => { + const normalizeBaseUrl = (url: string) => + url === "https://bare.example" ? "https://bare.example/v1" : url; + const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl }; + const reader = fakeReader({ baseUrls: { anthropic: "https://custom.example/anthropic" } }); + const fragment = buildAuthenticationFragment(reader, [descriptor]); + expect(fragment).toEqual({ + providers: { anthropic: { baseUrl: "https://custom.example/anthropic" } }, + }); + }); + + it("does not invoke the normalizer when baseUrl is unset", () => { + let calls = 0; + const normalizeBaseUrl = (url: string) => { + calls++; + return url; + }; + const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl }; + const fragment = buildAuthenticationFragment(fakeReader(), [descriptor]); + expect(fragment).toEqual({}); + expect(calls).toBe(0); + }); + + it("does not invoke the normalizer when baseUrl is an empty string", () => { + let calls = 0; + const normalizeBaseUrl = (url: string) => { + calls++; + return url; + }; + const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl }; + const reader = fakeReader({ baseUrls: { anthropic: "" } }); + const fragment = buildAuthenticationFragment(reader, [descriptor]); + expect(fragment).toEqual({}); + expect(calls).toBe(0); + }); + + it("behaves as before for descriptors without normalizeBaseUrl", () => { + const reader = fakeReader({ baseUrls: { anthropic: "https://bare.example" } }); + const fragment = buildAuthenticationFragment(reader, [ANTHROPIC]); + expect(fragment).toEqual({ + providers: { anthropic: { baseUrl: "https://bare.example" } }, + }); + }); + it("builds a multi-provider fragment, omitting providers with nothing set", () => { const reader = fakeReader({ baseUrls: { anthropic: "https://a.example" }, diff --git a/packages/ai-config/src/positron/authentication-fragment.ts b/packages/ai-config/src/positron/authentication-fragment.ts index 94143ac..291000e 100644 --- a/packages/ai-config/src/positron/authentication-fragment.ts +++ b/packages/ai-config/src/positron/authentication-fragment.ts @@ -63,6 +63,15 @@ export interface PositronAuthSettingDescriptor { * (+ `snowflake.customHeaders`), with `process.env` fallback for host/account. */ readonly read: "api-key-connection" | "aws-region" | "snowflake"; + /** + * Optional correction applied to the raw `baseUrl` setting value before it + * enters the fragment (only meaningful for `"api-key-connection"` reads). + * Injected by the consumer — e.g. the bridge's `normalizeBaseUrlForProvider`, + * which fixes bare known hosts missing their API version segment — so + * ai-config stays free of provider-specific URL knowledge and of any + * `ai-provider-bridge` import. + */ + readonly normalizeBaseUrl?: (url: string) => string; } /** @@ -111,7 +120,7 @@ function buildBlock( ): BuiltinProviderBlock | undefined { switch (descriptor.read) { case "api-key-connection": - return buildApiKeyBlock(reader, descriptor.configKey); + return buildApiKeyBlock(reader, descriptor.configKey, descriptor.normalizeBaseUrl); case "aws-region": return buildAwsRegionBlock(reader); case "snowflake": @@ -122,12 +131,13 @@ function buildBlock( function buildApiKeyBlock( reader: PositronAuthSettingReader, configKey: string, + normalizeBaseUrl?: (url: string) => string, ): BuiltinProviderBlock | undefined { const block: BuiltinProviderBlock = {}; const baseUrl = reader.getBaseUrl(configKey) || undefined; if (baseUrl) { - block.baseUrl = baseUrl; + block.baseUrl = normalizeBaseUrl ? normalizeBaseUrl(baseUrl) : baseUrl; } const customHeaders = normalizeHeaders(reader.getCustomHeaders(configKey)); diff --git a/packages/ai-provider-bridge/src/__tests__/base-url.test.ts b/packages/ai-provider-bridge/src/__tests__/base-url.test.ts new file mode 100644 index 0000000..b866fc4 --- /dev/null +++ b/packages/ai-provider-bridge/src/__tests__/base-url.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; + +import { normalizeBaseUrlForProvider } from "../base-url"; + +interface KnownProviderCase { + providerId: "anthropic" | "openai" | "gemini"; + host: string; + versioned: string; +} + +const KNOWN_PROVIDERS: KnownProviderCase[] = [ + { + providerId: "anthropic", + host: "https://api.anthropic.com", + versioned: "https://api.anthropic.com/v1", + }, + { providerId: "openai", host: "https://api.openai.com", versioned: "https://api.openai.com/v1" }, + { + providerId: "gemini", + host: "https://generativelanguage.googleapis.com", + versioned: "https://generativelanguage.googleapis.com/v1beta", + }, +]; + +describe.each(KNOWN_PROVIDERS)( + "normalizeBaseUrlForProvider ($providerId)", + ({ providerId, host, versioned }) => { + it("corrects a bare host to the versioned form", () => { + expect(normalizeBaseUrlForProvider(providerId, host)).toBe(versioned); + }); + + it("corrects a bare host with a trailing slash", () => { + expect(normalizeBaseUrlForProvider(providerId, `${host}/`)).toBe(versioned); + }); + + it("corrects a bare host with surrounding whitespace", () => { + expect(normalizeBaseUrlForProvider(providerId, ` ${host} `)).toBe(versioned); + }); + + it("corrects a bare host with both a trailing slash and surrounding whitespace", () => { + expect(normalizeBaseUrlForProvider(providerId, ` ${host}/ `)).toBe(versioned); + }); + + it("leaves an already-versioned host unchanged", () => { + expect(normalizeBaseUrlForProvider(providerId, versioned)).toBe(versioned); + }); + }, +); + +describe("normalizeBaseUrlForProvider: custom hosts", () => { + it("returns a custom host unchanged", () => { + const custom = "https://my-proxy.example/anthropic"; + expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom); + }); + + it("returns a custom host with a trailing slash byte-for-byte unchanged", () => { + const custom = "https://my-proxy.example/anthropic/"; + expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom); + }); + + it("returns a custom host with surrounding whitespace byte-for-byte unchanged", () => { + const custom = " https://my-proxy.example/anthropic "; + expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom); + }); + + it("returns an already-versioned host with surrounding whitespace byte-for-byte unchanged", () => { + const custom = " https://api.anthropic.com/v1 "; + expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom); + }); +}); + +describe("normalizeBaseUrlForProvider: unknown providers", () => { + it("returns a bare host unchanged for a provider with no known-host policy (deepseek)", () => { + const url = "https://api.deepseek.com"; + expect(normalizeBaseUrlForProvider("deepseek", url)).toBe(url); + }); + + it("returns a bare host unchanged for a provider with no known-host policy (bedrock)", () => { + const url = "https://bedrock-runtime.us-east-1.amazonaws.com"; + expect(normalizeBaseUrlForProvider("bedrock", url)).toBe(url); + }); +}); + +describe("normalizeBaseUrlForProvider: totality", () => { + it("never returns undefined; empty string in, empty string out", () => { + expect(normalizeBaseUrlForProvider("anthropic", "")).toBe(""); + }); +}); diff --git a/packages/ai-provider-bridge/src/__tests__/utils.test.ts b/packages/ai-provider-bridge/src/__tests__/utils.test.ts index e852e9d..522a9ab 100644 --- a/packages/ai-provider-bridge/src/__tests__/utils.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/utils.test.ts @@ -7,56 +7,9 @@ import { describe, expect, it } from "vitest"; import { buildSnowflakeCortexUrl, buildSnowflakeCortexUrlFromHost, - normalizeConfiguredBaseUrl, normalizeProviderBaseUrl, } from "../utils"; -describe("normalizeConfiguredBaseUrl", () => { - const HOST = "https://api.anthropic.com"; - - it("returns undefined when baseUrl is undefined", () => { - expect(normalizeConfiguredBaseUrl(undefined, HOST, "v1")).toBeUndefined(); - }); - - it("returns undefined when baseUrl is empty", () => { - expect(normalizeConfiguredBaseUrl("", HOST, "v1")).toBeUndefined(); - }); - - it("returns undefined when baseUrl is whitespace only", () => { - expect(normalizeConfiguredBaseUrl(" ", HOST, "v1")).toBeUndefined(); - }); - - it("appends the version segment when given the host with no version path", () => { - expect(normalizeConfiguredBaseUrl("https://api.anthropic.com", HOST, "v1")).toBe( - "https://api.anthropic.com/v1", - ); - }); - - it("normalizes a trailing slash on a host with no version path", () => { - expect(normalizeConfiguredBaseUrl("https://api.anthropic.com/", HOST, "v1")).toBe( - "https://api.anthropic.com/v1", - ); - }); - - it("leaves a host that already includes the version segment untouched", () => { - expect(normalizeConfiguredBaseUrl("https://api.anthropic.com/v1", HOST, "v1")).toBe( - "https://api.anthropic.com/v1", - ); - }); - - it("trims surrounding whitespace and a trailing slash from a custom host", () => { - expect(normalizeConfiguredBaseUrl(" https://my-proxy.example/anthropic/ ", HOST, "v1")).toBe( - "https://my-proxy.example/anthropic", - ); - }); - - it("leaves a custom proxy/gateway untouched", () => { - expect(normalizeConfiguredBaseUrl("https://my-proxy.example/anthropic", HOST, "v1")).toBe( - "https://my-proxy.example/anthropic", - ); - }); -}); - describe("normalizeProviderBaseUrl", () => { const HOST = "https://api.anthropic.com"; @@ -78,15 +31,15 @@ describe("normalizeProviderBaseUrl", () => { ); }); - it("appends the version segment when given the host with no version path", () => { + it("does not append the version segment to a bare host with no version path", () => { expect(normalizeProviderBaseUrl("https://api.anthropic.com", HOST, "v1")).toBe( - "https://api.anthropic.com/v1", + "https://api.anthropic.com", ); }); - it("normalizes a trailing slash on a host with no version path", () => { + it("trims a trailing slash on a bare host without appending the version segment", () => { expect(normalizeProviderBaseUrl("https://api.anthropic.com/", HOST, "v1")).toBe( - "https://api.anthropic.com/v1", + "https://api.anthropic.com", ); }); @@ -108,12 +61,17 @@ describe("normalizeProviderBaseUrl", () => { ); }); - it("supports non-v1 version segments (Gemini)", () => { + it("supports non-v1 version segments (Gemini) when unset", () => { const geminiHost = "https://generativelanguage.googleapis.com"; - expect(normalizeProviderBaseUrl(geminiHost, geminiHost, "v1beta")).toBe( + expect(normalizeProviderBaseUrl(undefined, geminiHost, "v1beta")).toBe( "https://generativelanguage.googleapis.com/v1beta", ); }); + + it("leaves a bare configured Gemini host bare (no version segment appended)", () => { + const geminiHost = "https://generativelanguage.googleapis.com"; + expect(normalizeProviderBaseUrl(geminiHost, geminiHost, "v1beta")).toBe(geminiHost); + }); }); describe("buildSnowflakeCortexUrl", () => { diff --git a/packages/ai-provider-bridge/src/base-url.ts b/packages/ai-provider-bridge/src/base-url.ts new file mode 100644 index 0000000..dd2f453 --- /dev/null +++ b/packages/ai-provider-bridge/src/base-url.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Bare-host base URL correction policy. + * + * `@ai-sdk/*` providers expect `baseURL` to already include the version segment + * (`/v1`, `/v1beta`) and append only the operation path, so a bare host like + * `https://api.anthropic.com` 404s. Historically Positron's `authentication.*` + * settings shipped such bare hosts as defaults; consumers (packages/positron) + * use this helper to correct those values at the config read seam and to + * rewrite the user's setting on disk. The bridge itself trusts the base URLs + * it is given — there is no chat-time normalization. + * + * This module owns the host/version constants (the client modules re-export + * them) and must stay dependency-light: it is reachable from the bridge ROOT + * entrypoint, which browser bundles import (via @assistant/core re-exports) — + * importing the client modules here would drag their Node-only dependencies + * (`crypto` via ai-sdk-helpers) into browser code. + */ + +import type { ProviderId } from "./types"; + +/** Anthropic public API host. `@ai-sdk/anthropic` expects baseURL to include `/v1`. */ +export const ANTHROPIC_HOST = "https://api.anthropic.com"; +/** Version segment `@ai-sdk/anthropic` expects appended to the host. */ +export const ANTHROPIC_API_VERSION = "v1"; + +/** OpenAI public API host. `@ai-sdk/openai` expects baseURL to include `/v1`. */ +export const OPENAI_HOST = "https://api.openai.com"; +/** Version segment `@ai-sdk/openai` expects appended to the host. */ +export const OPENAI_API_VERSION = "v1"; + +/** Gemini public API host. `@ai-sdk/google` expects baseURL to include `/v1beta`. */ +export const GEMINI_HOST = "https://generativelanguage.googleapis.com"; +/** Version segment `@ai-sdk/google` expects appended to the host. */ +export const GEMINI_API_VERSION = "v1beta"; + +/** Providers whose public API requires a version segment the SDK won't add. */ +const KNOWN_HOSTS: Partial> = { + anthropic: { host: ANTHROPIC_HOST, version: ANTHROPIC_API_VERSION }, + openai: { host: OPENAI_HOST, version: OPENAI_API_VERSION }, + gemini: { host: GEMINI_HOST, version: GEMINI_API_VERSION }, +}; + +/** + * Correct a bare known-provider host to its versioned form; return anything + * else unchanged. + * + * Matching is tolerant: the input is compared after trimming whitespace and + * trailing slashes, so `"https://api.anthropic.com/"` still corrects to + * `"https://api.anthropic.com/v1"`. But a non-matching input is returned + * **byte-for-byte** — no whitespace or trailing-slash cleanup — so + * `result !== url` means precisely "bare-host fix applied". Callers use that + * identity check directly as the write-back / notification criterion. + */ +export function normalizeBaseUrlForProvider(providerId: ProviderId, url: string): string { + const known = KNOWN_HOSTS[providerId]; + if (!known) return url; + + const candidate = url.trim().replace(/\/+$/, ""); + if (candidate === known.host) { + return `${known.host}/${known.version}`; + } + return url; +} diff --git a/packages/ai-provider-bridge/src/index.ts b/packages/ai-provider-bridge/src/index.ts index 0e28366..17d11dd 100644 --- a/packages/ai-provider-bridge/src/index.ts +++ b/packages/ai-provider-bridge/src/index.ts @@ -94,6 +94,10 @@ export { export { isLocalProviderId, LOCAL_PROVIDER_IDS, LocalProviderManager } from "./local-providers"; export type { LocalProviderId, LocalProviderManagerOptions } from "./local-providers"; +// Bare-host base URL correction (consumed by packages/positron to fix +// incorrect `authentication.*.baseUrl` values at the read seam and on disk) +export { normalizeBaseUrlForProvider } from "./base-url"; + // Small utilities export { isThinkingEnabled } from "./utils"; export { buildSnowflakeCortexUrl } from "./utils"; diff --git a/packages/ai-provider-bridge/src/model-clients/AnthropicClient.ts b/packages/ai-provider-bridge/src/model-clients/AnthropicClient.ts index 8372d11..27e3889 100644 --- a/packages/ai-provider-bridge/src/model-clients/AnthropicClient.ts +++ b/packages/ai-provider-bridge/src/model-clients/AnthropicClient.ts @@ -24,6 +24,10 @@ import type { ModelClient, ModelClientChatParams } from "./ModelClient"; /** Maximum number of web searches per request */ const WEB_SEARCH_MAX_USES = 5; +// Host/version constants live in base-url.ts (which must stay free of this +// module's Node-only imports); re-exported here for the provider modules. +export { ANTHROPIC_API_VERSION, ANTHROPIC_HOST } from "../base-url"; + export class AnthropicClient implements ModelClient { private readonly apiKey: string; private readonly baseURL?: string; @@ -36,6 +40,9 @@ export class AnthropicClient implements ModelClient { } async chat(params: ModelClientChatParams): Promise> { + // Per-request routing override wins over the constructor value. The URL is + // trusted as given — bare-host correction happens at the config seam + // (see base-url.ts), not here. const effectiveBaseUrl = params.baseUrl ?? this.baseURL; const headers = safeSdkCustomHeaders(this.customHeaders); const provider = createAnthropic({ diff --git a/packages/ai-provider-bridge/src/model-clients/GeminiClient.ts b/packages/ai-provider-bridge/src/model-clients/GeminiClient.ts index 9ac3fd5..959108b 100644 --- a/packages/ai-provider-bridge/src/model-clients/GeminiClient.ts +++ b/packages/ai-provider-bridge/src/model-clients/GeminiClient.ts @@ -27,6 +27,10 @@ import { } from "./ai-sdk-helpers"; import type { ModelClient, ModelClientChatParams } from "./ModelClient"; +// Host/version constants live in base-url.ts (which must stay free of this +// module's Node-only imports); re-exported here for the provider modules. +export { GEMINI_API_VERSION, GEMINI_HOST } from "../base-url"; + // --------------------------------------------------------------------------- // Interaction ID extraction (compaction-aware) // --------------------------------------------------------------------------- @@ -289,7 +293,9 @@ export class GeminiClient implements ModelClient { } async chat(params: ModelClientChatParams): Promise> { - // Create Google Generative AI provider + // Create Google Generative AI provider. Per-request routing override wins + // over the constructor value. The URL is trusted as given — bare-host + // correction happens at the config seam (see base-url.ts), not here. const effectiveBaseUrl = params.baseUrl ?? this.baseURL; const headers = safeSdkCustomHeaders(this.customHeaders); const provider = createGoogleGenerativeAI({ diff --git a/packages/ai-provider-bridge/src/model-clients/OpenAIClient.ts b/packages/ai-provider-bridge/src/model-clients/OpenAIClient.ts index c3420f9..88b3996 100644 --- a/packages/ai-provider-bridge/src/model-clients/OpenAIClient.ts +++ b/packages/ai-provider-bridge/src/model-clients/OpenAIClient.ts @@ -28,6 +28,10 @@ import type { ModelClient, ModelClientChatParams } from "./ModelClient"; type ApiMode = "completions" | "responses"; +// Host/version constants live in base-url.ts (which must stay free of this +// module's Node-only imports); re-exported here for the provider modules. +export { OPENAI_API_VERSION, OPENAI_HOST } from "../base-url"; + export class OpenAIClient implements ModelClient { private readonly apiKey?: string; private readonly baseURL?: string; @@ -66,6 +70,9 @@ export class OpenAIClient implements ModelClient { effectiveApiMode = this.apiMode; } + // Per-request routing override wins over the constructor value. The URL is + // trusted as given — bare-host correction happens at the config seam + // (see base-url.ts), not here. const effectiveBaseUrl = params.baseUrl ?? this.baseURL; // Create OpenAI provider. diff --git a/packages/ai-provider-bridge/src/model-clients/__tests__/base-url-normalization.test.ts b/packages/ai-provider-bridge/src/model-clients/__tests__/base-url-normalization.test.ts new file mode 100644 index 0000000..ce6b2f2 --- /dev/null +++ b/packages/ai-provider-bridge/src/model-clients/__tests__/base-url-normalization.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Capture the options passed to each SDK factory so we can assert on baseURL. +// `vi.hoisted` lets these mock fns exist before the hoisted `vi.mock` factories run. +const { createAnthropic, createOpenAI, createGoogleGenerativeAI } = vi.hoisted(() => ({ + createAnthropic: vi.fn(() => vi.fn(() => ({}))), + createOpenAI: vi.fn(() => ({ responses: vi.fn(() => ({})), chat: vi.fn(() => ({})) })), + createGoogleGenerativeAI: vi.fn(() => ({ interactions: vi.fn(() => ({})) })), +})); + +vi.mock("@ai-sdk/anthropic", () => ({ createAnthropic })); +vi.mock("@ai-sdk/openai", () => ({ createOpenAI })); +vi.mock("@ai-sdk/google", () => ({ createGoogleGenerativeAI })); +vi.mock("ai", () => ({ streamText: vi.fn(() => ({ fullStream: {} })) })); +// Bypass the stream-conversion + abort plumbing; we only care about baseURL. +vi.mock("../ai-sdk-helpers", () => ({ + convertAiSdkStreamToPlatform: vi.fn(() => (async function* () {})()), + createAbortControllerFromToken: vi.fn(() => ({ + abortController: new AbortController(), + cleanup: vi.fn(), + })), + createStepLogger: vi.fn(() => undefined), +})); + +import type { CancellationToken } from "../../types"; +import { AnthropicClient } from "../AnthropicClient"; +import { GeminiClient } from "../GeminiClient"; +import type { ModelClient, ModelClientChatParams } from "../ModelClient"; +import { OpenAIClient } from "../OpenAIClient"; + +const cancellationToken: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() {} }), +}; + +/** Read the `baseURL` option the client handed to its SDK factory. */ +function baseUrlPassedTo(factory: ReturnType): string | undefined { + const opts = factory.mock.calls[0]?.[0] as { baseURL?: string } | undefined; + return opts?.baseURL; +} + +interface ClientCase { + /** Public host whose bare form should gain the version segment. */ + host: string; + /** Fully versioned default the SDK expects (host + "/" + version). */ + versioned: string; + /** Build a client with an optional constructor-time base URL. */ + construct: (baseUrl?: string) => ModelClient; + /** Model ID accepted by the client's capability lookups. */ + model: string; + /** The mocked SDK factory that receives the resolved `baseURL`. */ + factory: ReturnType; +} + +const CASES: Record = { + Anthropic: { + host: "https://api.anthropic.com", + versioned: "https://api.anthropic.com/v1", + construct: (baseUrl) => new AnthropicClient("sk-test", baseUrl), + model: "claude-opus-4-8", + factory: createAnthropic, + }, + OpenAI: { + host: "https://api.openai.com", + versioned: "https://api.openai.com/v1", + construct: (baseUrl) => new OpenAIClient("sk-test", baseUrl, "responses"), + model: "gpt-5.4", + factory: createOpenAI, + }, + Gemini: { + host: "https://generativelanguage.googleapis.com", + versioned: "https://generativelanguage.googleapis.com/v1beta", + construct: (baseUrl) => new GeminiClient("sk-test", baseUrl), + model: "gemini-2.5-pro", + factory: createGoogleGenerativeAI, + }, +}; + +describe.each(Object.entries(CASES))("%s base URL pass-through", (_name, c) => { + const params = (overrides?: Partial): ModelClientChatParams => ({ + model: c.model, + messages: [], + maxOutputTokens: 1024, + cancellationToken, + ...overrides, + }); + + beforeEach(() => { + c.factory.mockClear(); + }); + + it("passes a per-request bare host straight through, unchanged", async () => { + await c.construct().chat(params({ baseUrl: c.host })); + expect(baseUrlPassedTo(c.factory)).toBe(c.host); + }); + + it("passes a bare host supplied at construction time straight through, unchanged", async () => { + await c.construct(c.host).chat(params()); + expect(baseUrlPassedTo(c.factory)).toBe(c.host); + }); + + it("prefers the per-request base URL over the constructor value", async () => { + await c.construct(c.versioned).chat(params({ baseUrl: "https://my-proxy.example/gateway" })); + expect(baseUrlPassedTo(c.factory)).toBe("https://my-proxy.example/gateway"); + }); + + it("leaves an already-versioned host untouched", async () => { + await c.construct().chat(params({ baseUrl: c.versioned })); + expect(baseUrlPassedTo(c.factory)).toBe(c.versioned); + }); + + it("passes a custom URL with a trailing slash through byte-for-byte", async () => { + const custom = "https://my-proxy.example/gateway/"; + await c.construct().chat(params({ baseUrl: custom })); + expect(baseUrlPassedTo(c.factory)).toBe(custom); + }); + + it("omits baseURL entirely when none is configured (SDK keeps its default)", async () => { + await c.construct().chat(params()); + expect(baseUrlPassedTo(c.factory)).toBeUndefined(); + }); +}); diff --git a/packages/ai-provider-bridge/src/providers/anthropic-provider.ts b/packages/ai-provider-bridge/src/providers/anthropic-provider.ts index 2633560..a9dd625 100644 --- a/packages/ai-provider-bridge/src/providers/anthropic-provider.ts +++ b/packages/ai-provider-bridge/src/providers/anthropic-provider.ts @@ -3,16 +3,17 @@ *--------------------------------------------------------------------------------------------*/ import { getAnthropicModelCapabilities } from "../model-capabilities/anthropic-helpers"; -import { AnthropicClient } from "../model-clients/AnthropicClient"; +import { + ANTHROPIC_API_VERSION, + ANTHROPIC_HOST, + AnthropicClient, +} from "../model-clients/AnthropicClient"; import type { Logger, ModelInfo } from "../types"; import type { ApiKeyCredentials } from "../types"; -import { normalizeConfiguredBaseUrl, normalizeProviderBaseUrl } from "../utils"; +import { normalizeProviderBaseUrl } from "../utils"; import { createCachedModelFetcher } from "./cached-model-fetcher"; import type { ProviderRegistry } from "./ProviderRegistry"; -/** Anthropic public API host. `@ai-sdk/anthropic` expects baseURL to include `/v1`. */ -const ANTHROPIC_HOST = "https://api.anthropic.com"; - /** * Models that are documented and usable but not yet returned by Anthropic's * `/v1/models` endpoint. We surface them here so they appear in the selector; @@ -59,7 +60,11 @@ export function registerAnthropicProvider(registry: ProviderRegistry, logger: Lo createCachedModelFetcher({ providerId: "anthropic", resolveUrl: (credentials) => { - const base = normalizeProviderBaseUrl(credentials.baseUrl, ANTHROPIC_HOST, "v1"); + const base = normalizeProviderBaseUrl( + credentials.baseUrl, + ANTHROPIC_HOST, + ANTHROPIC_API_VERSION, + ); return `${base}/models`; }, hasCredentials: (credentials) => Boolean(credentials.apiKey), @@ -90,10 +95,6 @@ export function registerAnthropicProvider(registry: ProviderRegistry, logger: Lo if (credentials.type !== "apikey") { throw new Error(`Anthropic provider requires API key credentials, got: ${credentials.type}`); } - return new AnthropicClient( - credentials.apiKey, - normalizeConfiguredBaseUrl(credentials.baseUrl, ANTHROPIC_HOST, "v1"), - credentials.customHeaders, - ); + return new AnthropicClient(credentials.apiKey, credentials.baseUrl, credentials.customHeaders); }); } diff --git a/packages/ai-provider-bridge/src/providers/gemini-provider.ts b/packages/ai-provider-bridge/src/providers/gemini-provider.ts index d7ca7e6..9fb4f88 100644 --- a/packages/ai-provider-bridge/src/providers/gemini-provider.ts +++ b/packages/ai-provider-bridge/src/providers/gemini-provider.ts @@ -6,16 +6,13 @@ import { getGeminiModelCapabilities, isInteractionsEligible, } from "../model-capabilities/gemini-helpers"; -import { GeminiClient } from "../model-clients/GeminiClient"; +import { GEMINI_API_VERSION, GEMINI_HOST, GeminiClient } from "../model-clients/GeminiClient"; import type { Logger, ModelInfo } from "../types"; import type { ApiKeyCredentials } from "../types"; -import { normalizeConfiguredBaseUrl, normalizeProviderBaseUrl } from "../utils"; +import { normalizeProviderBaseUrl } from "../utils"; import { createCachedModelFetcher } from "./cached-model-fetcher"; import type { ProviderRegistry } from "./ProviderRegistry"; -/** Gemini public API host. `@ai-sdk/google` expects baseURL to include `/v1beta`. */ -const GEMINI_HOST = "https://generativelanguage.googleapis.com"; - /** Default capabilities for unrecognized Gemini models */ const GEMINI_DEFAULT_CAPABILITIES: Partial = { supportsTools: true, @@ -71,7 +68,7 @@ export function registerGeminiProvider(registry: ProviderRegistry, logger: Logge providerId: "gemini", // Google requires API key in query string, not header resolveUrl: (credentials) => { - const base = normalizeProviderBaseUrl(credentials.baseUrl, GEMINI_HOST, "v1beta"); + const base = normalizeProviderBaseUrl(credentials.baseUrl, GEMINI_HOST, GEMINI_API_VERSION); const url = new URL("models", base + "/"); url.searchParams.set("key", credentials.apiKey); return url.toString(); @@ -140,7 +137,7 @@ export function registerGeminiProvider(registry: ProviderRegistry, logger: Logge } return new GeminiClient( credentials.apiKey, - normalizeConfiguredBaseUrl(credentials.baseUrl, GEMINI_HOST, "v1beta"), + credentials.baseUrl, credentials.customHeaders, logger, ); diff --git a/packages/ai-provider-bridge/src/providers/openai-provider.ts b/packages/ai-provider-bridge/src/providers/openai-provider.ts index b9fe910..55b0106 100644 --- a/packages/ai-provider-bridge/src/providers/openai-provider.ts +++ b/packages/ai-provider-bridge/src/providers/openai-provider.ts @@ -6,17 +6,14 @@ import { getOpenAIModelCapabilities, openaiMaxInputTokens, } from "../model-capabilities/openai-helpers"; -import { OpenAIClient } from "../model-clients/OpenAIClient"; +import { OPENAI_API_VERSION, OPENAI_HOST, OpenAIClient } from "../model-clients/OpenAIClient"; import type { Logger, ModelInfo } from "../types"; import type { ApiKeyCredentials } from "../types"; -import { normalizeConfiguredBaseUrl, normalizeProviderBaseUrl } from "../utils"; +import { normalizeProviderBaseUrl } from "../utils"; import { createCachedModelFetcher } from "./cached-model-fetcher"; import { getOpenAIModelName } from "./openai-model-names"; import type { ProviderRegistry } from "./ProviderRegistry"; -/** OpenAI public API host. `@ai-sdk/openai` expects baseURL to include `/v1`. */ -const OPENAI_HOST = "https://api.openai.com"; - /** Default capabilities for unrecognized OpenAI models (GPT-3.5, unknown) */ const OPENAI_DEFAULT_CAPABILITIES = { supportsTools: true, @@ -65,7 +62,7 @@ export function registerOpenAIProvider(registry: ProviderRegistry, logger: Logge createCachedModelFetcher({ providerId: "openai", resolveUrl: (credentials) => { - const base = normalizeProviderBaseUrl(credentials.baseUrl, OPENAI_HOST, "v1"); + const base = normalizeProviderBaseUrl(credentials.baseUrl, OPENAI_HOST, OPENAI_API_VERSION); return `${base}/models`; }, hasCredentials: (credentials) => Boolean(credentials.apiKey), @@ -125,7 +122,7 @@ export function registerOpenAIProvider(registry: ProviderRegistry, logger: Logge } return new OpenAIClient( credentials.apiKey, - normalizeConfiguredBaseUrl(credentials.baseUrl, OPENAI_HOST, "v1"), + credentials.baseUrl, "responses", undefined, credentials.customHeaders, diff --git a/packages/ai-provider-bridge/src/utils.ts b/packages/ai-provider-bridge/src/utils.ts index 5e2717c..9a8fdd0 100644 --- a/packages/ai-provider-bridge/src/utils.ts +++ b/packages/ai-provider-bridge/src/utils.ts @@ -65,53 +65,29 @@ export function isAgreementRequiredBody(responseBody: string | undefined): boole } // --------------------------------------------------------------------------- -// Base URL normalization +// Base URL fallback for model discovery // --------------------------------------------------------------------------- /** - * Normalize a configured base URL for an `@ai-sdk/*` provider, or return - * `undefined` when unset. + * Resolve the base URL for a direct model-discovery fetch: the configured + * value when set, else the versioned default (`host/version`). * - * `@ai-sdk/*` providers expect `baseURL` to already include the version segment - * (`/v1`, `/v1beta`) and append only the operation path, so a bare host like - * `https://api.anthropic.com` 404s. When the value is exactly the known host we - * add the version; any other host is left alone (custom proxies/gateways). - * - * Returning `undefined` when unset lets the SDK keep its default and base-URL - * env vars (`OPENAI_BASE_URL`, etc), which non-Positron hosts may rely on. + * The configured value only gets a trailing-slash/whitespace trim — URL-joining + * hygiene, since fetchers compose `${base}/models`. It is otherwise trusted as + * given: a bare known host is NOT corrected here (that policy lives in + * `base-url.ts` and is applied by consumers at the config seam). * * @param host Known public API host, no trailing slash (e.g. `https://api.anthropic.com`). - * @param version Version segment to ensure, no slashes (e.g. `v1`, `v1beta`). - */ -export function normalizeConfiguredBaseUrl( - baseUrl: string | undefined, - host: string, - version: string, -): string | undefined { - // undefined, empty, and whitespace-only all count as "unset". - const trimmed = baseUrl?.trim().replace(/\/+$/, ""); - if (!trimmed) return undefined; - - const hostTrimmed = host.replace(/\/+$/, ""); - if (trimmed === hostTrimmed) { - return `${hostTrimmed}/${version}`; - } - return trimmed; -} - -/** - * Like {@link normalizeConfiguredBaseUrl}, but falls back to the versioned - * default (`host/version`) when unset. For direct fetches (model discovery) - * that need a concrete URL and have no SDK env fallback to defer to. + * @param version Version segment of the default (e.g. `v1`, `v1beta`). */ export function normalizeProviderBaseUrl( baseUrl: string | undefined, host: string, version: string, ): string { - return ( - normalizeConfiguredBaseUrl(baseUrl, host, version) ?? `${host.replace(/\/+$/, "")}/${version}` - ); + // undefined, empty, and whitespace-only all count as "unset". + const trimmed = baseUrl?.trim().replace(/\/+$/, ""); + return trimmed || `${host.replace(/\/+$/, "")}/${version}`; } // ---------------------------------------------------------------------------