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
6 changes: 4 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ import {
import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js";
import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js";
import { selectableGoModelIds } from "../provider/opencode-go-models.js";
import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js";
import {
OPENCODE_GO_MESSAGES_PROVIDER,
ZEN_MESSAGES_PROVIDER,
} from "../provider/anthropic-session-adapter.js";
import { selectableZenModelIds } from "../provider/zen-models.js";
import {
OPENAI_RESPONSES_PROVIDER,
OPENAI_SESSION_ID_OPTION,
} from "../provider/openai-responses.js";
import { OPENCODE_SESSION_ID_OPTION } from "../provider/opencode-session.js";
import { OPENCODE_GO_MESSAGES_PROVIDER } from "../provider/opencode-go-anthropic-adapter.js";
import {
OPENCODE_GO_BASE_URL,
OPENCODE_GO_PROVIDER_ID,
Expand Down
2 changes: 1 addition & 1 deletion src/config/inference-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-ada
import { createInferenceDependencies } from "../provider/inference-dependencies.js";
import { clearSourceCredentials } from "./source-credentials.js";
import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses.js";
import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js";
import { ZEN_MESSAGES_PROVIDER } from "../provider/anthropic-session-adapter.js";
import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js";
import {
ZEN_DEFAULT_BASE_URL,
Expand Down
4 changes: 2 additions & 2 deletions src/provider/anthropic-cache-breakpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type {
LastCycleSource,
} from "@intx/types/runtime";
import { withAnthropicCacheBreakpoint } from "./anthropic-cache-breakpoint.js";
import { createOpenCodeGoAnthropicAdapter } from "./opencode-go-anthropic-adapter.js";
import { createZenAnthropicAdapter } from "./zen-anthropic-adapter.js";
import { createOpenCodeGoAnthropicAdapter } from "./anthropic-session-adapter.js";
import { createZenAnthropicAdapter } from "./anthropic-session-adapter.js";

function sourceFor(provider: string): LastCycleSource {
return { sourceId: `test-${provider}`, provider, model: "test-model" };
Expand Down
6 changes: 4 additions & 2 deletions src/provider/anthropic-cache-breakpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import type {
ExtendedInferenceOptions,
ProviderAdapter,
} from "@intx/inference";
import { OPENCODE_GO_MESSAGES_PROVIDER } from "./opencode-go-anthropic-adapter.js";
import { ZEN_MESSAGES_PROVIDER } from "./zen-anthropic-adapter.js";
import {
OPENCODE_GO_MESSAGES_PROVIDER,
ZEN_MESSAGES_PROVIDER,
} from "./anthropic-session-adapter.js";

const ANTHROPIC_MESSAGES_PROVIDERS: ReadonlySet<string> = new Set([
"anthropic",
Expand Down
73 changes: 73 additions & 0 deletions src/provider/anthropic-session-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import type { InferenceOptions } from "@intx/types/runtime";
import {
createOpenCodeGoAnthropicAdapter,
createSessionHeaderAnthropicAdapter,
createZenAnthropicAdapter,
} from "./anthropic-session-adapter.js";

const messages = [
{
role: "user" as const,
timestamp: 0,
content: [{ type: "text" as const, text: "hi" }],
},
];

const factories = {
"opencode-go": createOpenCodeGoAnthropicAdapter,
zen: createZenAnthropicAdapter,
} as const;

describe("session header Anthropic adapter", () => {
for (const [name, factory] of Object.entries(factories)) {
test(`${name}: delegates Anthropic request construction and adds only the session header`, () => {
const request = factory({
sourceId: name,
provider: `${name}-messages`,
model: "minimax-m3",
}).buildRequest(messages, "minimax-m3", {
providerOptions: { opencodeSessionId: "sess-1" },
} as InferenceOptions);
expect(request.headers["x-opencode-session"]).toBe("sess-1");
expect(JSON.parse(request.body)).not.toHaveProperty("opencodeSessionId");
expect(JSON.parse(request.body)).toMatchObject({
model: "minimax-m3",
stream: true,
});
});

test(`${name}: omits the session header when no session is supplied`, () => {
const request = factory({
sourceId: name,
provider: `${name}-messages`,
model: "minimax-m3",
}).buildRequest(messages, "minimax-m3", {});
expect(request.headers["x-opencode-session"]).toBeUndefined();
});
}

test("named factories match the shared wrapper byte-for-byte", () => {
const source = {
sourceId: "shared",
provider: "zen-messages",
model: "minimax-m3",
};
const options = {
providerOptions: { opencodeSessionId: "sess-1" },
} as InferenceOptions;
const shared = createSessionHeaderAnthropicAdapter(source).buildRequest(
messages,
"minimax-m3",
options,
);
for (const factory of Object.values(factories)) {
const request = factory(source).buildRequest(
messages,
"minimax-m3",
options,
);
expect(request).toEqual(shared);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {
optionString,
} from "./opencode-session.js";

export const ZEN_MESSAGES_PROVIDER = "zen-messages";
export const OPENCODE_GO_MESSAGES_PROVIDER = "opencode-go-messages";

type AdapterSource = Parameters<typeof createAnthropicAdapter>[0];

export function createOpenCodeGoAnthropicAdapter(
export function createSessionHeaderAnthropicAdapter(
source: AdapterSource,
quirks?: unknown,
): ProviderAdapter {
Expand All @@ -30,3 +31,17 @@ export function createOpenCodeGoAnthropicAdapter(
};
return { ...base, buildRequest };
}

export function createZenAnthropicAdapter(
source: AdapterSource,
quirks?: unknown,
): ProviderAdapter {
return createSessionHeaderAnthropicAdapter(source, quirks);
}

export function createOpenCodeGoAnthropicAdapter(
source: AdapterSource,
quirks?: unknown,
): ProviderAdapter {
return createSessionHeaderAnthropicAdapter(source, quirks);
}
210 changes: 210 additions & 0 deletions src/provider/bounded-model-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { type } from "arktype";

import { requestModelsEndpoint } from "./models-endpoint.js";

const CatalogModelsResponse = type({
data: type({ id: "string" }).array(),
});

export type CatalogDiscoveryState =
| { readonly status: "models"; readonly models: readonly string[] }
| { readonly status: "empty" }
| { readonly status: "unavailable"; readonly message: string }
| { readonly status: "malformed"; readonly message: string };

export function createBoundedModelCatalog(args: {
baseURL: string;
seedIds: readonly string[];
catalogLabel: string;
maxBytes: number;
maxModels: number;
}): {
discoverModels: (args?: {
timeoutMs?: number;
signal?: AbortSignal;
}) => Promise<CatalogDiscoveryState>;
selectableModelIds: () => readonly string[];
prefetchModels: () => Promise<readonly string[]>;
resetDiscoveryForTests: () => void;
} {
const { baseURL, seedIds, catalogLabel, maxBytes, maxModels } = args;

let inflight: Promise<readonly string[]> | undefined;
let snapshot: readonly string[] | undefined;

function declaredCatalogBytes(response: Response): number | undefined {
const raw = response.headers.get("content-length");
if (raw === null || raw.length === 0) return undefined;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return undefined;
return n;
}

function oversizeMessage(kind: "bytes" | "models"): string {
if (kind === "bytes") {
return `${catalogLabel} catalog exceeds ${String(maxBytes)} bytes`;
}
return `${catalogLabel} catalog exceeds ${String(maxModels)} models`;
}

async function readCatalogJson(
response: Response,
): Promise<
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly message: string }
> {
const declared = declaredCatalogBytes(response);
if (declared !== undefined && declared > maxBytes) {
await response.body?.cancel().catch(() => undefined);
return { ok: false, message: oversizeMessage("bytes") };
}

const body = response.body;
if (body === null) {
try {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > maxBytes) {
return { ok: false, message: oversizeMessage("bytes") };
}
const value: unknown = JSON.parse(text);
return { ok: true, value };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
}
}

const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value === undefined) continue;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
return { ok: false, message: oversizeMessage("bytes") };
}
chunks.push(value);
}
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
}

const buffer = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.byteLength;
}

try {
const value: unknown = JSON.parse(new TextDecoder().decode(buffer));
return { ok: true, value };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
}
}

async function discoverModels(args?: {
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CatalogDiscoveryState> {
let response: Response;
try {
response = await requestModelsEndpoint({
baseURL,
...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
...(args?.signal !== undefined ? { signal: args.signal } : {}),
});
} catch (error) {
return {
status: "unavailable",
message: error instanceof Error ? error.message : String(error),
};
}

if (!response.ok) {
return {
status: "unavailable",
message: `${catalogLabel} returned HTTP ${String(response.status)}`,
};
}

const body = await readCatalogJson(response);
if (!body.ok) {
return { status: "malformed", message: body.message };
}
const parsed = CatalogModelsResponse(body.value);
if (parsed instanceof type.errors) {
return { status: "malformed", message: parsed.summary };
}
if (parsed.data.length > maxModels) {
return { status: "malformed", message: oversizeMessage("models") };
}
const models = [
...new Set(
parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0),
),
];
return models.length > 0
? { status: "models", models }
: { status: "empty" };
}

function selectableModelIds(): readonly string[] {
return snapshot ?? seedIds;
}

async function runPrefetch(): Promise<readonly string[]> {
const state = await discoverModels();
// Empty/unavailable/malformed leave a successful snapshot in place:
// stale-but-live beats empty, and a cold failure still falls through
// to the packaged seed.
if (state.status === "models") {
snapshot = state.models;
}
return selectableModelIds();
}

function prefetchModels(): Promise<readonly string[]> {
if (inflight !== undefined) return inflight;

const pending = runPrefetch();
inflight = pending;
// Clear inflight on settle so a later prefetch can recover instead of
// replaying the first settlement forever. .then(cleanup, cleanup)
// instead of .finally() avoids an abandoned promise chain whose
// pass-through rejection could become an unhandled rejection — callers
// await the original pending promise.
const cleanup = (): void => {
if (inflight === pending) {
inflight = undefined;
}
};
pending.then(cleanup, cleanup);
return pending;
}

function resetDiscoveryForTests(): void {
inflight = undefined;
snapshot = undefined;
}

return {
discoverModels,
selectableModelIds,
prefetchModels,
resetDiscoveryForTests,
};
}
Loading
Loading