Skip to content

Commit 844733a

Browse files
authored
feat(providers): add Sakana AI provider with Fugu models (#5169)
* feat(providers): add Sakana AI provider with Fugu models OpenAI-compatible provider at https://api.sakana.ai/v1 (bearer auth). Registers fugu (fast default) and fugu-ultra (reasoning flagship), both 1M context. BYOK-only, never hosted/auto-billed. Streaming, tool loop, and response_format supported; attachments mirror deepseek (unsupported in the current adapter). * fix(providers): defer Sakana structured output until after tool loop OpenAI-compatible backends reject a request carrying both response_format and active tools/tool_choice. Mirror the LiteLLM pattern: withhold the JSON schema while tools are active and apply it on a final tool-free call (tool_choice: none) for both streaming and non-streaming paths. * fix(providers): harden Sakana tool-loop error + final-stream tool_choice - Rethrow tool-loop failures instead of swallowing them, so a failed run surfaces as a ProviderError rather than a partial success (matches LiteLLM). - Force tool_choice: 'none' on the post-tool streaming pass so the model cannot emit fresh tool calls that the text-only stream adapter would drop. * fix(providers): Sakana streaming usage + filtered-tools stream guard - Pass stream_options: { include_usage: true } on both streaming calls so token/cost data is captured (the shared OpenAI-compatible stream helper only fills usage from chunk usage, which the API omits without the flag). - Include !hasActiveTools in the early-stream guard so requests whose tools are all filtered out (e.g. usageControl 'none') still take the fast streaming path instead of the tool-loop path. Mirrors LiteLLM. * fix(providers): answer every Sakana tool_call to keep message history valid An assistant message lists all tool_calls, so a call for an unconfigured tool must still get a matching `tool` response or the next request violates the OpenAI message contract. Emit an error tool-result for unknown tools instead of dropping them. * test(session): de-flake SessionProvider normal-load test flush() only drained microtasks, so the query->render update occasionally lost the race and ctx.data was still null after the flush budget. Yield one macrotask tick per flush so React Query's notifyManager and deferred renders settle deterministically. Verified across repeated local runs.
1 parent e96b150 commit 844733a

11 files changed

Lines changed: 751 additions & 2 deletions

File tree

apps/sim/app/_shell/providers/session-provider.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,18 @@ function renderProvider(): Harness {
107107
}
108108
}
109109

110-
/** Flush pending microtasks inside an act() boundary. */
110+
/**
111+
* Flush pending work inside an act() boundary. Drains the microtask queue and
112+
* then yields one macrotask tick, so React Query's notifyManager (which can
113+
* schedule observer notifications on a timer) and any deferred renders settle
114+
* deterministically — microtask-only flushing raced the query→render update.
115+
*/
111116
async function flush() {
112117
await act(async () => {
113118
await Promise.resolve()
114119
await Promise.resolve()
115120
await Promise.resolve()
121+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
116122
})
117123
}
118124

apps/sim/components/icons.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3439,6 +3439,16 @@ export const DeepseekIcon = (props: SVGProps<SVGSVGElement>) => (
34393439
</svg>
34403440
)
34413441

3442+
export const SakanaIcon = (props: SVGProps<SVGSVGElement>) => (
3443+
<svg {...props} height='1em' viewBox='152 5 38 30' width='1em' xmlns='http://www.w3.org/2000/svg'>
3444+
<title>Sakana AI</title>
3445+
<path
3446+
d='m187.2 7.8-2.5-0.7c-6.3-1.8-12.7-1.2-18 1.5l-10.2 5.7c-1.2 0.7-0.2 2.5 1 1.8l7.6-4.4c0.8 1.7 1.5 4 1.1 7.7-1.4-0.3-6-1.4-10.9 1.5-0.6 0.3-0.8 1.1-0.3 1.7 0.5 0.5 1.2 0.3 1.3 0.2 2.2-1.3 5.6-2.4 9.6-1.4-0.7 2.5-2.5 5.6-6 7.8-1.5 0.7-0.4 2.3 0.7 1.8 1.8-1 5.3-3.4 6.9-9 2.1 0.9 4.2 2.4 5.9 4.6l-7.2 4.1c-1.2 0.6-0.3 2.4 1.1 1.7l9-5c4.6-2.7 8.3-7.5 10.1-13.1l1.3-5.3c0.4-0.4 0-1.1-0.5-1.2zm-11.5 17.5-0.6 0.4c-2-2.6-4.5-4.7-7.5-5.7 0.5-3.8-0.3-6.8-1.2-9.1l1.1-0.6c4.8-2 9.8-2.7 16.2-0.9l1.6 0.4-0.8 2.7c-1.5 4.9-4.5 9.6-8.8 12.8z'
3447+
fill='#E60000'
3448+
/>
3449+
</svg>
3450+
)
3451+
34423452
export function GeminiIcon(props: SVGProps<SVGSVGElement>) {
34433453
const id = useId()
34443454
const gradientId = `gemini_gradient_${id}`

apps/sim/lib/tokenization/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export const TOKENIZATION_CONFIG = {
5656
confidence: 'medium',
5757
supportedMethods: ['heuristic', 'fallback'],
5858
},
59+
sakana: {
60+
avgCharsPerToken: 4,
61+
confidence: 'medium',
62+
supportedMethods: ['heuristic', 'fallback'],
63+
},
5964
ollama: {
6065
avgCharsPerToken: 4,
6166
confidence: 'low',

apps/sim/providers/attachments.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type AttachmentProvider =
3535
| 'xai'
3636
| 'deepseek'
3737
| 'cerebras'
38+
| 'sakana'
3839

3940
export interface PreparedProviderAttachment {
4041
file: UserFile
@@ -118,7 +119,7 @@ const BEDROCK_DOCUMENT_FORMATS = new Set([
118119
const BEDROCK_IMAGE_FORMATS = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp'])
119120
const BEDROCK_VIDEO_FORMATS = new Set(['mp4', 'mov', 'mkv', 'webm'])
120121

121-
const UNSUPPORTED_FILE_PROVIDERS = new Set<AttachmentProvider>(['deepseek', 'cerebras'])
122+
const UNSUPPORTED_FILE_PROVIDERS = new Set<AttachmentProvider>(['deepseek', 'cerebras', 'sakana'])
122123

123124
const PROVIDER_SUPPORTED_LABELS: Record<AttachmentProvider, string> = {
124125
openai: 'images and documents through the Responses API input_image/input_file parts',
@@ -137,6 +138,7 @@ const PROVIDER_SUPPORTED_LABELS: Record<AttachmentProvider, string> = {
137138
xai: 'images through image_url message parts on Grok vision models',
138139
deepseek: 'no file attachments in the current API adapter',
139140
cerebras: 'no file attachments in the current API adapter',
141+
sakana: 'no file attachments in the current API adapter',
140142
}
141143

142144
export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null {
@@ -156,6 +158,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme
156158
if (providerId === 'xai') return 'xai'
157159
if (providerId === 'deepseek') return 'deepseek'
158160
if (providerId === 'cerebras') return 'cerebras'
161+
if (providerId === 'sakana') return 'sakana'
159162
return null
160163
}
161164

@@ -303,6 +306,7 @@ function isMimeTypeSupportedByProvider(
303306
return isImageMimeType(mimeType)
304307
case 'deepseek':
305308
case 'cerebras':
309+
case 'sakana':
306310
return false
307311
default: {
308312
const _exhaustive: never = provider

apps/sim/providers/models.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,35 @@ describe('orderModelIdsByReleaseDate', () => {
102102
expect([...ordered].sort()).toEqual([...input].sort())
103103
})
104104
})
105+
106+
describe('sakana provider definition', () => {
107+
const sakana = PROVIDER_DEFINITIONS.sakana
108+
109+
it('is registered with fugu as the default model', () => {
110+
expect(sakana).toBeDefined()
111+
expect(sakana.id).toBe('sakana')
112+
expect(sakana.defaultModel).toBe('fugu')
113+
expect(sakana.modelPatterns).toEqual([/^fugu/])
114+
})
115+
116+
it('exposes fugu and fugu-ultra with a 1M context window', () => {
117+
expect(sakana.models.map((m) => m.id)).toEqual(['fugu', 'fugu-ultra'])
118+
for (const model of sakana.models) {
119+
expect(model.contextWindow).toBe(1000000)
120+
}
121+
})
122+
123+
it('prices both models at the documented fugu-ultra ceiling', () => {
124+
for (const model of sakana.models) {
125+
expect(model.pricing.input).toBe(5)
126+
expect(model.pricing.output).toBe(30)
127+
expect(model.pricing.cachedInput).toBe(0.5)
128+
}
129+
})
130+
131+
it('routes bare fugu model IDs to the sakana provider', () => {
132+
const baseModels = getBaseModelProviders()
133+
expect(baseModels.fugu).toBe('sakana')
134+
expect(baseModels['fugu-ultra']).toBe('sakana')
135+
})
136+
})

apps/sim/providers/models.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
OllamaIcon,
2424
OpenAIIcon,
2525
OpenRouterIcon,
26+
SakanaIcon,
2627
TogetherIcon,
2728
VertexIcon,
2829
VllmIcon,
@@ -2197,6 +2198,47 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
21972198
},
21982199
],
21992200
},
2201+
sakana: {
2202+
id: 'sakana',
2203+
name: 'Sakana AI',
2204+
description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API",
2205+
defaultModel: 'fugu',
2206+
modelPatterns: [/^fugu/],
2207+
icon: SakanaIcon,
2208+
color: '#E60000',
2209+
capabilities: {
2210+
temperature: { min: 0, max: 2 },
2211+
toolUsageControl: true,
2212+
},
2213+
models: [
2214+
{
2215+
id: 'fugu',
2216+
pricing: {
2217+
input: 5,
2218+
cachedInput: 0.5,
2219+
output: 30,
2220+
updatedAt: '2026-06-22',
2221+
},
2222+
capabilities: {},
2223+
contextWindow: 1000000,
2224+
releaseDate: '2026-06-15',
2225+
speedOptimized: true,
2226+
},
2227+
{
2228+
id: 'fugu-ultra',
2229+
pricing: {
2230+
input: 5,
2231+
cachedInput: 0.5,
2232+
output: 30,
2233+
updatedAt: '2026-06-22',
2234+
},
2235+
capabilities: {},
2236+
contextWindow: 1000000,
2237+
releaseDate: '2026-06-15',
2238+
recommended: true,
2239+
},
2240+
],
2241+
},
22002242
mistral: {
22012243
id: 'mistral',
22022244
name: 'Mistral AI',

apps/sim/providers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ollamaProvider } from '@/providers/ollama'
1616
import { ollamaCloudProvider } from '@/providers/ollama-cloud'
1717
import { openaiProvider } from '@/providers/openai'
1818
import { openRouterProvider } from '@/providers/openrouter'
19+
import { sakanaProvider } from '@/providers/sakana'
1920
import { togetherProvider } from '@/providers/together'
2021
import type { ProviderConfig, ProviderId } from '@/providers/types'
2122
import { vertexProvider } from '@/providers/vertex'
@@ -34,6 +35,7 @@ const providerRegistry: Record<ProviderId, ProviderConfig> = {
3435
xai: xAIProvider,
3536
cerebras: cerebrasProvider,
3637
groq: groqProvider,
38+
sakana: sakanaProvider,
3739
vllm: vllmProvider,
3840
litellm: litellmProvider,
3941
mistral: mistralProvider,

0 commit comments

Comments
 (0)