Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/litellm-gateway-preset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@open-codesign/shared": minor
"@open-codesign/i18n": patch
"@open-codesign/desktop": patch
---

Add a first-class LiteLLM Gateway provider preset for externally hosted gateways. Users can add it from Settings without hand-rolling a custom provider, using either a proxy key or a keyless / IP-allowlist deployment. LiteLLM is not bundled.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ Add a `SKILL.md` to any project to teach the model your own taste.
## What you get

### Models and providers
- **Unified provider model** — Anthropic, OpenAI, Gemini, DeepSeek, OpenRouter, SiliconFlow, local Ollama, or any OpenAI-compatible relay; keyless (IP-allowlisted) proxies supported
- **Unified provider model** — Anthropic, OpenAI, Gemini, DeepSeek, OpenRouter, SiliconFlow, local Ollama, LiteLLM Gateway, or any OpenAI-compatible relay; keyless (IP-allowlisted) proxies supported
- **One-click import and sign-in** — bring Claude Code / Codex API-key provider configs across, or sign in with ChatGPT subscription for Codex models
- **Dynamic model picker** — every provider exposes its real model catalogue, not a hardcoded shortlist

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ scoop install opencoworkai/open-codesign
## 你能得到什么

### 模型与提供商
- **统一的 provider 抽象**:支持 Anthropic、OpenAI、Gemini、DeepSeek、OpenRouter、SiliconFlow、本地 Ollama,以及任意 OpenAI-compatible relay;同时支持无 key 的 IP 白名单代理
- **统一的 provider 抽象**:支持 Anthropic、OpenAI、Gemini、DeepSeek、OpenRouter、SiliconFlow、本地 Ollama、LiteLLM Gateway,以及任意 OpenAI-compatible relay;同时支持无 key 的 IP 白名单代理
- **一键导入和登录**:Claude Code / Codex 的 API key provider 配置可以直接带进来,也可以用 ChatGPT 订阅登录使用 Codex 模型
- **动态模型选择器**:每个 provider 都会展示真实模型列表,而不是一小撮写死的选项

Expand Down
15 changes: 6 additions & 9 deletions apps/desktop/src/main/connection-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1374,22 +1374,19 @@ describe('config:v1:test-endpoint response parsing', () => {
}
});

it('rejects empty API keys before attempting fetch', async () => {
const { restore } = installFakeFetch(() => {
throw new Error('fetch should not be called');
});
it('allows empty API keys so keyless gateways can be probed', async () => {
const { restore } = installFakeFetch(() => ({
status: 200,
body: { data: [{ id: 'gpt-4o' }] },
}));
try {
await expect(
handleConfigV1TestEndpoint({
wire: 'openai-chat',
baseUrl: 'https://provider.example/v1',
apiKey: ' ',
}),
).resolves.toEqual({
ok: false,
error: 'bad-input',
message: 'apiKey must be a non-empty string',
});
).resolves.toEqual({ ok: true, modelCount: 1, models: ['gpt-4o'] });
} finally {
restore();
}
Expand Down
8 changes: 3 additions & 5 deletions apps/desktop/src/main/connection-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,14 +1275,12 @@ function parseTestEndpointPayload(raw: unknown): TestEndpointPayload {
if (typeof apiKey !== 'string') {
throw new CodesignError('apiKey must be a string', ERROR_CODES.IPC_BAD_INPUT);
}
const trimmedApiKey = apiKey.trim();
if (trimmedApiKey.length === 0) {
throw new CodesignError('apiKey must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT);
}
// Empty apiKey is a keyless probe (LiteLLM IP-allowlist / disable_auth,
// CLIProxyAPI without api-keys). Auth headers already omit Bearer when empty.
const out: TestEndpointPayload = {
wire,
baseUrl: parseHttpBaseUrl(baseUrl, 'baseUrl'),
apiKey: trimmedApiKey,
apiKey: apiKey.trim(),
};
if (r['allowPrivateNetwork'] !== undefined) {
if (typeof r['allowPrivateNetwork'] !== 'boolean') {
Expand Down
72 changes: 72 additions & 0 deletions apps/desktop/src/main/onboarding-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,78 @@ describe('config:v1 provider mutations — fail-fast key handling', () => {
expect(buildSecretRef).not.toHaveBeenCalled();
});

it('lets LiteLLM-style custom providers save keyless without storing an empty secret', async () => {
const { readConfig, writeConfig } = await import('./config');
const { buildSecretRef } = await import('./keychain');
vi.mocked(readConfig).mockResolvedValueOnce(null);
vi.mocked(writeConfig).mockClear();
vi.mocked(buildSecretRef).mockClear();
const { loadConfigOnBoot, registerOnboardingIpc } = await import('./onboarding-ipc');
await loadConfigOnBoot();
registerOnboardingIpc();
const handler = handlers.get('config:v1:add-provider');
if (!handler) throw new Error('handler missing');

const state = await handler({} as never, {
id: 'custom-litellm-gateway-ab12',
name: 'LiteLLM Gateway',
wire: 'openai-chat',
baseUrl: 'http://localhost:4000/v1',
apiKey: '',
defaultModel: 'gpt-4o',
setAsActive: true,
requiresApiKey: false,
});

expect(buildSecretRef).not.toHaveBeenCalled();
const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0];
expect(written?.activeProvider).toBe('custom-litellm-gateway-ab12');
expect(written?.secrets['custom-litellm-gateway-ab12']).toBeUndefined();
expect(written?.providers['custom-litellm-gateway-ab12']).toMatchObject({
id: 'custom-litellm-gateway-ab12',
name: 'LiteLLM Gateway',
wire: 'openai-chat',
baseUrl: 'http://localhost:4000/v1',
defaultModel: 'gpt-4o',
requiresApiKey: false,
});
expect(state).toMatchObject({
hasKey: true,
provider: 'custom-litellm-gateway-ab12',
});
});

it('stores a LiteLLM proxy key while keeping the entry explicitly keyless-capable', async () => {
const { readConfig, writeConfig } = await import('./config');
const { buildSecretRef } = await import('./keychain');
vi.mocked(readConfig).mockResolvedValueOnce(null);
vi.mocked(writeConfig).mockClear();
vi.mocked(buildSecretRef).mockClear();
const { loadConfigOnBoot, registerOnboardingIpc } = await import('./onboarding-ipc');
await loadConfigOnBoot();
registerOnboardingIpc();
const handler = handlers.get('config:v1:add-provider');
if (!handler) throw new Error('handler missing');

await handler({} as never, {
id: 'custom-litellm-gateway-key',
name: 'LiteLLM Gateway',
wire: 'openai-chat',
baseUrl: 'https://litellm.internal.example/v1',
apiKey: 'sk-litellm-master',
defaultModel: 'gpt-4o',
setAsActive: true,
requiresApiKey: false,
});

expect(buildSecretRef).toHaveBeenCalledWith('sk-litellm-master');
const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0];
expect(written?.secrets['custom-litellm-gateway-key']).toEqual(
expect.objectContaining({ ciphertext: 'enc:sk-litellm-master' }),
);
expect(written?.providers['custom-litellm-gateway-key']?.requiresApiKey).toBe(false);
});

it('rejects malformed custom-provider header maps instead of dropping bad entries', async () => {
const { registerOnboardingIpc } = await import('./onboarding-ipc');
registerOnboardingIpc();
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/main/onboarding/provider-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export interface AddCustomProviderInput {
/** Per-provider TLS verification opt-out (#229). Built-in providers
* force-ignore this flag at runtime. */
tlsRejectUnauthorized?: boolean;
/**
* When false, an empty apiKey is allowed and the stored entry is marked
* keyless (LiteLLM IP-allowlist / `disable_auth` deployments). Omit or
* true keeps the existing "key required" contract for generic custom
* providers.
*/
requiresApiKey?: boolean;
setAsActive: boolean;
}

Expand Down Expand Up @@ -74,6 +81,7 @@ const ADD_PROVIDER_FIELDS = [
'queryParams',
'envKey',
'tlsRejectUnauthorized',
'requiresApiKey',
'setAsActive',
] as const;
const UPDATE_PROVIDER_FIELDS = [
Expand Down Expand Up @@ -287,7 +295,11 @@ export function parseAddProviderPayload(raw: unknown): AddCustomProviderInput {
if (typeof apiKey !== 'string') {
throw new CodesignError('apiKey must be a string', ERROR_CODES.IPC_BAD_INPUT);
}
if (apiKey.trim().length === 0) {
if (r['requiresApiKey'] !== undefined && typeof r['requiresApiKey'] !== 'boolean') {
throw new CodesignError('requiresApiKey must be a boolean', ERROR_CODES.IPC_BAD_INPUT);
}
const allowEmptyKey = r['requiresApiKey'] === false;
if (apiKey.trim().length === 0 && !allowEmptyKey) {
throw new CodesignError('apiKey must be a non-empty string', ERROR_CODES.IPC_BAD_INPUT);
}
if (typeof defaultModel !== 'string' || defaultModel.trim().length === 0) {
Expand All @@ -306,6 +318,7 @@ export function parseAddProviderPayload(raw: unknown): AddCustomProviderInput {
defaultModel: defaultModel.trim(),
setAsActive,
};
if (r['requiresApiKey'] === false) out.requiresApiKey = false;
const headers = stringMapFromOptional(r['httpHeaders'], 'httpHeaders');
if (headers !== undefined && Object.keys(headers).length > 0) out.httpHeaders = headers;
const qp = stringMapFromOptional(r['queryParams'], 'queryParams');
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/main/onboarding/providers-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export async function runAddCustomProvider(
input: AddCustomProviderInput,
): Promise<OnboardingState> {
const cachedConfig = getCachedConfig();
const allowKeyless = input.requiresApiKey === false;
const entry: ProviderEntry = {
id: input.id,
name: input.name,
Expand All @@ -215,10 +216,13 @@ export async function runAddCustomProvider(
...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}),
...(input.envKey !== undefined ? { envKey: input.envKey } : {}),
...(input.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}),
...(allowKeyless ? { requiresApiKey: false } : {}),
};
const secretRef = buildSecretRef(input.apiKey);
const nextProviders = { ...(cachedConfig?.providers ?? {}), [entry.id]: entry };
const nextSecrets = { ...(cachedConfig?.secrets ?? {}), [entry.id]: secretRef };
const nextSecrets = { ...(cachedConfig?.secrets ?? {}) };
if (input.apiKey.length > 0) {
nextSecrets[entry.id] = buildSecretRef(input.apiKey);
}
const shouldActivate = input.setAsActive || cachedConfig === null;
const next = hydrateConfig({
version: 3,
Expand Down
Loading
Loading