diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2b99ce2c1..5db45dd1c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added + +- Added support for Sakana AI provider +- Added Sakana AI login and request base URL support for `SAKANA_*` / `FUGU_*` environment variables ### Changed diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 9844917cc..24c0aa4af 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -130,6 +130,17 @@ export interface OpenAIRequestSetup { requestHeaders: Record; } +function normalizeSakanaRequestBaseUrl(baseUrl: string | undefined): string | undefined { + const value = baseUrl?.trim(); + if (!value) return undefined; + const normalized = value.replace(/\/+$/, ""); + return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; +} + +function resolveSakanaRequestBaseUrl(): string | undefined { + return normalizeSakanaRequestBaseUrl($env.SAKANA_BASE_URL) ?? normalizeSakanaRequestBaseUrl($env.FUGU_BASE_URL); +} + export function resolveOpenAIRequestSetup( model: OpenAIRequestSetupModel, options: OpenAIRequestSetupOptions, @@ -165,6 +176,12 @@ export function resolveOpenAIRequestSetup( baseUrl = moonshotBaseUrl; } } + if (model.provider === "sakana") { + const sakanaBaseUrl = resolveSakanaRequestBaseUrl(); + if (sakanaBaseUrl) { + baseUrl = sakanaBaseUrl; + } + } if (model.provider === "github-copilot") { apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken; const copilot = buildCopilotDynamicHeaders({ diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index fff0e5fcc..d089bd89c 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -44,6 +44,7 @@ import { parallelProvider } from "./parallel"; import { perplexityProvider } from "./perplexity"; import { qianfanProvider } from "./qianfan"; import { qwenPortalProvider } from "./qwen-portal"; +import { sakanaProvider } from "./sakana"; import { syntheticProvider } from "./synthetic"; import { tavilyProvider } from "./tavily"; import { togetherProvider } from "./together"; @@ -90,6 +91,7 @@ const ALL = [ zhipuCodingPlanProvider, umansProvider, qwenPortalProvider, + sakanaProvider, minimaxCodeProvider, minimaxCodeCnProvider, xiaomiProvider, diff --git a/packages/ai/src/registry/sakana.ts b/packages/ai/src/registry/sakana.ts new file mode 100644 index 000000000..d2bf1070c --- /dev/null +++ b/packages/ai/src/registry/sakana.ts @@ -0,0 +1,22 @@ +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const loginSakana = createApiKeyLogin({ + providerLabel: "Sakana AI", + authUrl: "https://console.sakana.ai/api-keys", + instructions: "Copy your API key from the Sakana AI console", + promptMessage: "Paste your Sakana AI API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "Sakana AI", + modelsUrl: "https://api.sakana.ai/v1/models", + }, +}); + +export const sakanaProvider = { + id: "sakana", + name: "Sakana AI", + login: (cb: OAuthLoginCallbacks) => loginSakana(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/test/sakana-provider.test.ts b/packages/ai/test/sakana-provider.test.ts new file mode 100644 index 000000000..7ae91ce75 --- /dev/null +++ b/packages/ai/test/sakana-provider.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { resolveOpenAIRequestSetup } from "@oh-my-pi/pi-ai/providers/openai-shared"; + +const ORIGINAL_ENV = { + SAKANA_BASE_URL: Bun.env.SAKANA_BASE_URL, + FUGU_BASE_URL: Bun.env.FUGU_BASE_URL, +} as const; + +function restoreEnvVar(name: keyof typeof ORIGINAL_ENV): void { + const value = ORIGINAL_ENV[name]; + if (value === undefined) { + delete Bun.env[name]; + return; + } + Bun.env[name] = value; +} + +afterEach(() => { + restoreEnvVar("SAKANA_BASE_URL"); + restoreEnvVar("FUGU_BASE_URL"); +}); + +describe("Sakana AI request base URL override", () => { + const sakanaModel = { + provider: "sakana", + id: "fugu", + baseUrl: "https://api.sakana.ai/v1", + }; + + test("uses SAKANA_BASE_URL and appends /v1 for bundled Sakana models", () => { + Bun.env.SAKANA_BASE_URL = "https://gateway.sakana.test/"; + delete Bun.env.FUGU_BASE_URL; + + const setup = resolveOpenAIRequestSetup(sakanaModel, { + apiKey: "sakana-key", + messages: [], + }); + + expect(setup.baseUrl).toBe("https://gateway.sakana.test/v1"); + }); + + test("falls back to FUGU_BASE_URL when SAKANA_BASE_URL is unset", () => { + delete Bun.env.SAKANA_BASE_URL; + Bun.env.FUGU_BASE_URL = "https://fugu.sakana.test/v1"; + + const setup = resolveOpenAIRequestSetup(sakanaModel, { + apiKey: "sakana-key", + messages: [], + }); + + expect(setup.baseUrl).toBe("https://fugu.sakana.test/v1"); + }); + + test("does not redirect other OpenAI-compatible providers", () => { + Bun.env.SAKANA_BASE_URL = "https://gateway.sakana.test"; + + const setup = resolveOpenAIRequestSetup( + { provider: "openai", id: "gpt-5.5", baseUrl: "https://api.openai.com/v1" }, + { apiKey: "openai-key", messages: [] }, + ); + + expect(setup.baseUrl).toBe("https://api.openai.com/v1"); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index ef2ebf7c1..9022a8365 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## [Unreleased] + ### Added +- Added Sakana AI provider support with Fugu model integration +- Added Sakana AI/Fugu provider catalog entries with Fugu model discovery and Responses API metadata - Added support for "xhigh" reasoning tier across model configurations - Added configuration for new models GCP-5.4 Mini, GPT-5.5, and variants - Added `devin` variant collapse table to streamline model tiering diff --git a/packages/catalog/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts index 1aa670b20..d8d601d1c 100644 --- a/packages/catalog/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -37,6 +37,7 @@ import { isKimiK27CodeModelId, MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels, + SAKANA_FUGU_STATIC_MODELS, stripFireworksDeepSeekThinkingToggle, } from "../src/provider-models/openai-compat"; import type { ModelSpec } from "../src/types"; @@ -490,6 +491,12 @@ async function generateModels() { // Mythos 5). Deduped behind upstream entries; metadata is pinned in // applyAnthropicCatalogPolicy. allModels.push(...ANTHROPIC_CURATED_FALLBACK_MODELS); + // Seed Sakana's documented Fugu models so the provider is usable when + // catalog generation has no live API key. If live `/v1/models` succeeds, + // Sakana is authoritative and stale seed IDs must stay out. + if (!authoritativeCatalogProviders.has("sakana")) { + allModels.push(...SAKANA_FUGU_STATIC_MODELS); + } // Seed Fireworks "Fast" serving-path variants (`-fast`). Fast routers are // not enumerated by the serverless control-plane list, so discovery never // surfaces them; the seed projects each base entry into a fast variant. diff --git a/packages/catalog/src/model-thinking.ts b/packages/catalog/src/model-thinking.ts index ea6b4bd58..588e7510e 100644 --- a/packages/catalog/src/model-thinking.ts +++ b/packages/catalog/src/model-thinking.ts @@ -62,6 +62,10 @@ const GPT_5_1_CODEX_MINI_EFFORTS: readonly Effort[] = [Effort.Medium, Effort.Hig const LOW_MEDIUM_HIGH_REASONING_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High]; const GLM_52_HIGH_MAX_REASONING_EFFORTS: readonly Effort[] = [Effort.High, Effort.XHigh]; +const FUGU_REASONING_EFFORTS: readonly Effort[] = [Effort.High, Effort.XHigh]; +const FUGU_REASONING_EFFORT_MAP: Readonly = { + [Effort.XHigh]: "max", +}; type EffortMap = Partial>; const GROQ_QWEN3_32B_REASONING_EFFORT_MAP: Readonly = { @@ -306,6 +310,9 @@ function getModelDefinedEfforts( return GLM_52_HIGH_MAX_REASONING_EFFORTS; } } + if (isSakanaFuguReasoningModel(spec)) { + return FUGU_REASONING_EFFORTS; + } return isOpenAICompatReasoningApi(spec.api) && (isMinimaxM2FamilyModelId(spec.id) || isOpenAIGptOssModelId(spec.id) || @@ -382,6 +389,9 @@ function inferDetectedEffortMap( if (isOllamaCloudGlm52ReasoningEffortModel(spec)) { return GLM_52_XHIGH_MAX_EFFORT_MAP; } + if (isSakanaFuguReasoningModel(spec)) { + return FUGU_REASONING_EFFORT_MAP; + } if (!isOpenAICompatReasoningApi(spec.api)) { return undefined; } @@ -405,6 +415,10 @@ function inferDetectedEffortMap( return map; } +function isSakanaFuguReasoningModel(spec: ModelSpec): boolean { + return spec.provider === "sakana" && /^fugu(?:$|-)/i.test(spec.id); +} + function isDeepseekReasoningModel(spec: ModelSpec): boolean { if (!spec.reasoning) return false; const lowerId = spec.id.toLowerCase(); diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 87c3088db..eeddcdd1e 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -69242,6 +69242,104 @@ "maxTokens": 8192 } }, + "sakana": { + "fugu": { + "id": "fugu", + "name": "Fugu", + "api": "openai-responses", + "provider": "sakana", + "baseUrl": "https://api.sakana.ai/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": null, + "maxTokens": null, + "thinking": { + "mode": "effort", + "efforts": [ + "high", + "xhigh" + ], + "effortMap": { + "xhigh": "max" + } + }, + "compat": { + "includeEncryptedReasoning": false + } + }, + "fugu-ultra": { + "id": "fugu-ultra", + "name": "Fugu Ultra", + "api": "openai-responses", + "provider": "sakana", + "baseUrl": "https://api.sakana.ai/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 5, + "output": 30, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "contextWindow": null, + "maxTokens": null, + "thinking": { + "mode": "effort", + "efforts": [ + "high", + "xhigh" + ], + "effortMap": { + "xhigh": "max" + } + }, + "compat": { + "includeEncryptedReasoning": false + } + }, + "fugu-ultra-20260615": { + "id": "fugu-ultra-20260615", + "name": "Fugu Ultra 20260615", + "api": "openai-responses", + "provider": "sakana", + "baseUrl": "https://api.sakana.ai/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 5, + "output": 30, + "cacheRead": 0.5, + "cacheWrite": 0 + }, + "contextWindow": null, + "maxTokens": null, + "thinking": { + "mode": "effort", + "efforts": [ + "high", + "xhigh" + ], + "effortMap": { + "xhigh": "max" + } + }, + "compat": { + "includeEncryptedReasoning": false + } + } + }, "synthetic": { "hf:MiniMaxAI/MiniMax-M3": { "id": "hf:MiniMaxAI/MiniMax-M3", diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 5a2c87484..6b9692434 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -35,6 +35,7 @@ import { openrouterModelManagerOptions, qianfanModelManagerOptions, qwenPortalModelManagerOptions, + sakanaModelManagerOptions, syntheticModelManagerOptions, togetherModelManagerOptions, umansModelManagerOptions, @@ -308,6 +309,14 @@ export const CATALOG_PROVIDERS = [ oauthProvider: "qwen-portal", }, }, + { + id: "sakana", + defaultModel: "fugu", + envVars: ["SAKANA_API_KEY", "FUGU_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => sakanaModelManagerOptions(config), + dynamicModelsAuthoritative: true, + catalogDiscovery: { label: "Sakana AI" }, + }, { id: "synthetic", defaultModel: "hf:zai-org/GLM-5.1", diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 90a6d8dd1..36af7f0a2 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -2520,6 +2520,100 @@ export function moonshotModelManagerOptions( }; } +// --------------------------------------------------------------------------- +// 16.5 Sakana AI +// --------------------------------------------------------------------------- + +const SAKANA_DEFAULT_BASE_URL = "https://api.sakana.ai/v1"; +const SAKANA_FREE_ROUTER_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const; +const SAKANA_FUGU_ULTRA_COST = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 } as const; +const SAKANA_FUGU_THINKING: ThinkingConfig = { + mode: "effort", + efforts: [Effort.High, Effort.XHigh], + effortMap: { [Effort.XHigh]: "max" }, +}; +const SAKANA_RESPONSES_COMPAT: ModelSpec<"openai-responses">["compat"] = { + includeEncryptedReasoning: false, +}; + +function normalizeSakanaBaseUrl(baseUrl: string | undefined): string { + const value = baseUrl?.trim() || SAKANA_DEFAULT_BASE_URL; + const normalized = value.replace(/\/+$/, ""); + return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; +} + +function isSakanaFuguModelId(modelId: string): boolean { + return /^fugu(?:$|-)/i.test(modelId); +} + +function createSakanaFuguStaticModel( + id: string, + name: string, + cost: ModelSpec<"openai-responses">["cost"], +): ModelSpec<"openai-responses"> { + return { + id, + name, + api: "openai-responses", + provider: "sakana", + baseUrl: SAKANA_DEFAULT_BASE_URL, + reasoning: true, + input: ["text"], + cost: { ...cost }, + contextWindow: null, + maxTokens: null, + thinking: { ...SAKANA_FUGU_THINKING }, + compat: { ...SAKANA_RESPONSES_COMPAT }, + }; +} + +export const SAKANA_FUGU_STATIC_MODELS: readonly ModelSpec<"openai-responses">[] = [ + createSakanaFuguStaticModel("fugu", "Fugu", SAKANA_FREE_ROUTER_COST), + createSakanaFuguStaticModel("fugu-ultra", "Fugu Ultra", SAKANA_FUGU_ULTRA_COST), + createSakanaFuguStaticModel("fugu-ultra-20260615", "Fugu Ultra 20260615", SAKANA_FUGU_ULTRA_COST), +]; + +const SAKANA_FUGU_STATIC_MODEL_BY_ID = new Map(SAKANA_FUGU_STATIC_MODELS.map(model => [model.id, model] as const)); + +export interface SakanaModelManagerConfig { + apiKey?: string; + baseUrl?: string; + fetch?: FetchImpl; +} + +export function sakanaModelManagerOptions(config?: SakanaModelManagerConfig): ModelManagerOptions<"openai-responses"> { + const apiKey = config?.apiKey; + const baseUrl = normalizeSakanaBaseUrl(config?.baseUrl ?? Bun.env.SAKANA_BASE_URL ?? Bun.env.FUGU_BASE_URL); + const references = createBundledReferenceMap<"openai-responses">("sakana"); + return { + providerId: "sakana", + dynamicModelsAuthoritative: true, + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-responses", + provider: "sakana", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id) ?? SAKANA_FUGU_STATIC_MODEL_BY_ID.get(defaults.id); + const model = mapWithBundledReference(entry, defaults, reference); + if (!reference && isSakanaFuguModelId(model.id)) { + return { + ...model, + reasoning: true, + thinking: { ...SAKANA_FUGU_THINKING }, + compat: { ...SAKANA_RESPONSES_COMPAT }, + }; + } + return model; + }, + fetch: config?.fetch, + }), + }), + }; +} + // --------------------------------------------------------------------------- // 17. Qwen Portal // --------------------------------------------------------------------------- diff --git a/packages/catalog/test/sakana-provider.test.ts b/packages/catalog/test/sakana-provider.test.ts new file mode 100644 index 000000000..f8d6a2cc0 --- /dev/null +++ b/packages/catalog/test/sakana-provider.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test, vi } from "bun:test"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { sakanaModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl, ResolvedOpenAIResponsesCompat } from "@oh-my-pi/pi-catalog/types"; + +const ORIGINAL_ENV = { + SAKANA_API_KEY: Bun.env.SAKANA_API_KEY, + FUGU_API_KEY: Bun.env.FUGU_API_KEY, + SAKANA_BASE_URL: Bun.env.SAKANA_BASE_URL, + FUGU_BASE_URL: Bun.env.FUGU_BASE_URL, +} as const; + +function restoreEnvVar(name: keyof typeof ORIGINAL_ENV): void { + const value = ORIGINAL_ENV[name]; + if (value === undefined) { + delete Bun.env[name]; + return; + } + Bun.env[name] = value; +} + +afterEach(() => { + restoreEnvVar("SAKANA_API_KEY"); + restoreEnvVar("FUGU_API_KEY"); + restoreEnvVar("SAKANA_BASE_URL"); + restoreEnvVar("FUGU_BASE_URL"); + vi.restoreAllMocks(); +}); + +describe("Sakana AI provider support", () => { + test("resolves Sakana and Fugu API key environment fallbacks", () => { + delete Bun.env.SAKANA_API_KEY; + Bun.env.FUGU_API_KEY = "fugu-test-key"; + expect(getEnvApiKey("sakana")).toBe("fugu-test-key"); + + Bun.env.SAKANA_API_KEY = "sakana-test-key"; + expect(getEnvApiKey("sakana")).toBe("sakana-test-key"); + }); + + test("registers descriptor, default model, bundled Fugu models, and login provider", () => { + const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "sakana"); + expect(descriptor).toBeDefined(); + expect(descriptor?.defaultModel).toBe("fugu"); + expect(descriptor?.catalogDiscovery?.envVars).toEqual(["SAKANA_API_KEY", "FUGU_API_KEY"]); + expect(descriptor?.dynamicModelsAuthoritative).toBe(true); + expect(DEFAULT_MODEL_PER_PROVIDER.sakana).toBe("fugu"); + + const bundled = getBundledModels("sakana"); + expect(bundled.map(model => model.id).sort()).toEqual(["fugu", "fugu-ultra", "fugu-ultra-20260615"]); + for (const model of bundled) { + expect(model.api).toBe("openai-responses"); + expect(model.thinking?.efforts).toEqual([Effort.High, Effort.XHigh]); + expect(model.thinking?.effortMap?.[Effort.XHigh]).toBe("max"); + expect((model.compat as ResolvedOpenAIResponsesCompat).includeEncryptedReasoning).toBe(false); + } + + const provider = getOAuthProviders().find(item => item.id === "sakana"); + expect(provider?.name).toBe("Sakana AI"); + }); + + test("discovers models from Sakana Models API with normalized base URL and curated Fugu metadata", async () => { + delete Bun.env.SAKANA_BASE_URL; + Bun.env.FUGU_BASE_URL = "https://gateway.sakana.test"; + const fetchMock: FetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ data: [{ id: "fugu" }, { id: "fugu-ultra" }, { id: "fugu-next" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as FetchImpl; + + const options = sakanaModelManagerOptions({ apiKey: "sakana-key", fetch: fetchMock }); + expect(options.dynamicModelsAuthoritative).toBe(true); + const models = await options.fetchDynamicModels?.(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://gateway.sakana.test/v1/models", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ Authorization: "Bearer sakana-key" }), + }), + ); + expect(models?.map(model => model.id)).toEqual(["fugu", "fugu-next", "fugu-ultra"]); + const fuguNext = models?.find(model => model.id === "fugu-next"); + expect(fuguNext?.reasoning).toBe(true); + expect(fuguNext?.thinking?.efforts).toEqual([Effort.High, Effort.XHigh]); + expect(fuguNext?.thinking?.effortMap?.[Effort.XHigh]).toBe("max"); + expect(fuguNext?.compat?.includeEncryptedReasoning).toBe(false); + }); + + test("prefers explicit Sakana model-manager base URL over environment aliases", async () => { + Bun.env.SAKANA_BASE_URL = "https://env.sakana.test"; + const fetchMock: FetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ data: [{ id: "fugu" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as FetchImpl; + + const options = sakanaModelManagerOptions({ + apiKey: "sakana-key", + baseUrl: "https://config.sakana.test/api", + fetch: fetchMock, + }); + await options.fetchDynamicModels?.(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://config.sakana.test/api/v1/models", + expect.objectContaining({ method: "GET" }), + ); + }); +});