Skip to content
Draft
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
13 changes: 13 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ location /api/ {

Both modes feed the **same** `<artifact>` parser and the **same** sandboxed iframe. The only thing that differs is the transport and the system-prompt delivery (local CLIs have no separate system channel, so the composed prompt is folded into the user message).

### OpenRouter API mode

OpenRouter uses the OpenAI-compatible API path. Create an API key from [OpenRouter Keys](https://openrouter.ai/settings/keys), then in **Settings -> Execution -> API mode**, choose **OpenAI**, pick **OpenRouter** from quick-fill providers, paste the key, and select a model such as `openai/gpt-5.2` or `openrouter/auto`.

The preset fills:

```text
Base URL: https://openrouter.ai/api/v1
Model: openai/gpt-5.2
```

If you configure OpenRouter manually instead of using the preset, make sure the protocol tab is **OpenAI** before pasting `https://openrouter.ai/api/v1`; the Anthropic and Gemini protocol tabs use different upstream request shapes. OpenRouter 401, 402, or 429 responses usually mean the API key, account credits, free-model availability, or rate limits need attention on the OpenRouter side rather than in Open Design.

## Prompt composition

For every send, the app builds a system prompt from three layers and sends it to the provider:
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/state/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [
model: 'gpt-4o',
models: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'],
},
{
label: 'OpenRouter',
protocol: 'openai',
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-5.2',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The model IDs here (openai/gpt-5.2, openrouter/auto, etc.) may not match modelMaxTokensDefault lookup keys in litellm-models.json. If they fall back to 8192, large artifact responses could be truncated. Consider explicit overrides or OpenRouter-aware normalization.

models: [
'openai/gpt-5.2',
'openrouter/auto',
'anthropic/claude-sonnet-4.5',
'google/gemini-3-flash-preview',
'x-ai/grok-4',
],
},
{
label: 'Azure OpenAI',
protocol: 'azure',
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/state/maxTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ const OVERRIDES: Record<string, number> = {
'mimo-v2.5-pro': 32768,
};

function litellmMaxTokens(model: string): number | undefined {
return LITELLM_MODELS[model] ?? LITELLM_MODELS[`openrouter/${model}`];
}

export function modelMaxTokensDefault(model: string): number {
return OVERRIDES[model] ?? LITELLM_MODELS[model] ?? FALLBACK_MAX_TOKENS;
return OVERRIDES[model] ?? litellmMaxTokens(model) ?? FALLBACK_MAX_TOKENS;
}

function isValidOverride(value: number | undefined): value is number {
Expand Down
28 changes: 28 additions & 0 deletions apps/web/tests/components/SettingsDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
updateAgentCliEnvValue,
updateCurrentApiProtocolConfig,
} from '../../src/components/SettingsDialog';
import { KNOWN_PROVIDERS } from '../../src/state/config';
import type { AppConfig } from '../../src/types';

const baseConfig: AppConfig = {
Expand Down Expand Up @@ -93,6 +94,33 @@ describe('SettingsDialog API protocol switching', () => {
});
});

it('keeps OpenRouter as a selectable OpenAI-compatible provider preset', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test manually patches baseUrl/model, so it would still pass if OpenRouter were removed from KNOWN_PROVIDERS. Consider asserting the KNOWN_PROVIDERS entry or simulating actual provider selection from the Settings UI to prove discoverability.

expect(KNOWN_PROVIDERS).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: 'OpenRouter',
protocol: 'openai',
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-5.2',
}),
]),
);

const openai = switchApiProtocolConfig(baseConfig, 'openai');
const next = updateCurrentApiProtocolConfig(openai, {
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-5.2',
apiProviderBaseUrl: 'https://openrouter.ai/api/v1',
});

expect(next).toMatchObject({
apiProtocol: 'openai',
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-5.2',
apiProviderBaseUrl: 'https://openrouter.ai/api/v1',
});
});

it('auto-fills Google defaults when switching from a selected known provider', () => {
expect(switchApiProtocolConfig(baseConfig, 'google')).toMatchObject({
mode: 'api',
Expand Down
5 changes: 5 additions & 0 deletions apps/web/tests/providers/openai-compatible.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ describe('isOpenAICompatible', () => {
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/v1')).toBe(true);
});

it('routes OpenRouter through the OpenAI-compatible chat completions proxy', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This routing test only covers the isOpenAICompatible heuristic. It doesn't prove the OpenRouter preset actually flows through /api/proxy/openai/stream at runtime. An integration-style provider test using the preset config would catch regressions.

expect(isOpenAICompatible('openai/gpt-5.2', 'https://openrouter.ai/api/v1')).toBe(true);
expect(isOpenAICompatible('openrouter/auto', 'https://openrouter.ai/api/v1')).toBe(true);
});

it('routes MiniMax Anthropic endpoint paths away from OpenAI-compatible chat completions', () => {
expect(isOpenAICompatible('MiniMax-M2.7-highspeed', 'https://api.minimaxi.com/v1/anthropic')).toBe(false);
expect(isOpenAICompatible('MiniMax-M2.7-highspeed', 'https://api.minimaxi.com/anthropic/v1')).toBe(false);
Expand Down
42 changes: 42 additions & 0 deletions apps/web/tests/providers/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { reattachDaemonRun, streamViaDaemon } from '../../src/providers/daemon';
import { streamMessageOpenAI } from '../../src/providers/openai-compatible';
import { parseSseFrame } from '../../src/providers/sse';
import { KNOWN_PROVIDERS } from '../../src/state/config';

afterEach(() => {
vi.unstubAllGlobals();
Expand Down Expand Up @@ -549,6 +550,47 @@ describe('streamMessageOpenAI', () => {
expect(handlers.onDelta).toHaveBeenCalledWith('hi');
expect(handlers.onDone).toHaveBeenCalledWith('hi');
});

it('sends the OpenRouter preset through the OpenAI proxy with its token default', async () => {
const openRouter = KNOWN_PROVIDERS.find((provider) => provider.label === 'OpenRouter');
if (!openRouter) throw new Error('OpenRouter provider preset missing');
const handlers = createStreamHandlers();
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
sseResponse('event: end\ndata: {}\n\n'),
);
vi.stubGlobal('fetch', fetchMock);

await streamMessageOpenAI(
{
mode: 'api',
apiKey: 'test-key',
apiProtocol: 'openai',
apiProviderBaseUrl: openRouter.baseUrl,
baseUrl: openRouter.baseUrl,
model: openRouter.model,
agentId: null,
skillId: null,
designSystemId: null,
},
'',
[{ id: '1', role: 'user', content: 'hello' }],
new AbortController().signal,
handlers,
);

expect(fetchMock).toHaveBeenCalledWith('/api/proxy/openai/stream', expect.objectContaining({
body: expect.any(String),
method: 'POST',
}));
const [, init] = fetchMock.mock.calls[0]!;
const body = JSON.parse(String((init as RequestInit).body));
expect(body).toMatchObject({
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-5.2',
maxTokens: 128000,
});
expect(handlers.onError).not.toHaveBeenCalled();
});
});

function createStreamHandlers() {
Expand Down
11 changes: 11 additions & 0 deletions apps/web/tests/state/maxTokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';

import litellmData from '../../src/state/litellm-models.json';
import { KNOWN_PROVIDERS } from '../../src/state/config';
import {
effectiveMaxTokens,
FALLBACK_MAX_TOKENS,
Expand Down Expand Up @@ -29,6 +30,16 @@ describe('modelMaxTokensDefault', () => {
expect(modelMaxTokensDefault('definitely-not-a-real-model-x9z')).toBe(FALLBACK_MAX_TOKENS);
expect(FALLBACK_MAX_TOKENS).toBe(8192);
});

it('normalizes OpenRouter provider model ids to LiteLLM OpenRouter aliases', () => {
const openRouter = KNOWN_PROVIDERS.find((provider) => provider.label === 'OpenRouter');
expect(openRouter?.models?.length).toBeGreaterThan(0);

for (const model of openRouter?.models ?? []) {
expect((litellmData.models as Record<string, number>)[model]).toBeUndefined();
expect(modelMaxTokensDefault(model)).not.toBe(FALLBACK_MAX_TOKENS);
}
});
});

describe('effectiveMaxTokens', () => {
Expand Down