diff --git a/web/src/features/home/ConnectionCard.test.ts b/web/src/features/home/ConnectionCard.test.ts new file mode 100644 index 00000000..ab5f9af4 --- /dev/null +++ b/web/src/features/home/ConnectionCard.test.ts @@ -0,0 +1,69 @@ +import { mount } from '@vue/test-utils' + +import type { AccessKeyOptionDto } from '@/api/control/types' +import AppSelect from '@/components/ui/AppSelect.vue' +import { createTestAppI18n } from '@/test/i18n' + +import ConnectionCard from './ConnectionCard.vue' + +function mountCard() { + const appI18n = createTestAppI18n(undefined, 'en-US') + return mount(ConnectionCard, { + props: { + origin: 'https://gateway.example.com', + keys: [ + { + id: 7, + name: 'Production metadata', + status: 'active', + key: 'ACCESS_KEY_CANARY', + } as AccessKeyOptionDto & { key: string }, + ], + modelIds: ['gpt-4o', 'claude-sonnet-4', 'gemini-2.5-flash'], + }, + global: { + plugins: [appI18n.plugin], + stubs: { + RouterLink: { + props: ['to'], + template: '', + }, + }, + }, + }) +} + +describe('ConnectionCard', () => { + it('requires an explicit model choice and never renders AccessKey plaintext', async () => { + const wrapper = mountCard() + + expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain('') + expect(wrapper.html()).not.toContain('ACCESS_KEY_CANARY') + + await wrapper.get('[data-test="connection-model"]').setValue('claude-sonnet-4') + const protocolSelect = wrapper + .findAllComponents(AppSelect) + .find((select) => select.props('label') === 'Protocol') + if (!protocolSelect) throw new Error('missing protocol select') + protocolSelect.vm.$emit('update:modelValue', 'anthropic') + await wrapper.vm.$nextTick() + + expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain('/v1/messages') + expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain( + 'x-api-key: $GPT_LOAD_API_KEY', + ) + expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain( + 'anthropic-version:', + ) + }) + + it('lets the user explicitly collapse and restore Connection Setup', async () => { + const wrapper = mountCard() + + expect(wrapper.find('[data-test="connection-content"]').exists()).toBe(true) + await wrapper.get('[data-test="connection-toggle"]').trigger('click') + expect(wrapper.find('[data-test="connection-content"]').exists()).toBe(false) + await wrapper.get('[data-test="connection-toggle"]').trigger('click') + expect(wrapper.find('[data-test="connection-content"]').exists()).toBe(true) + }) +}) diff --git a/web/src/features/home/ConnectionCard.vue b/web/src/features/home/ConnectionCard.vue index 94ff6fbc..c11e2e37 100644 --- a/web/src/features/home/ConnectionCard.vue +++ b/web/src/features/home/ConnectionCard.vue @@ -1,18 +1,25 @@ @@ -122,6 +196,19 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v align-items: flex-start; gap: var(--space-3); } +.connection-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} +.connection-card__header :deep(.app-button) { + gap: var(--space-1); +} +.connection-card__content { + display: grid; + gap: var(--space-5); +} .connection-card h2 { margin: 0; font-size: 1.0625rem; @@ -140,6 +227,28 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v font-size: 0.8125rem; font-weight: 650; } +.connection-card__choices { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: var(--space-3); +} +.connection-card__choices label { + display: grid; + gap: var(--space-2); +} +.connection-card__choices :deep(.app-select__trigger), +.connection-card__choices input { + width: 100%; +} +.connection-card__choices input { + min-height: var(--touch-target); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-control); + background: var(--color-surface); + color: var(--color-text); + padding: var(--space-2) var(--space-3); + font: inherit; +} .connection-card__value { display: flex; min-width: 0; @@ -156,7 +265,8 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v overflow-wrap: anywhere; } .loopback-note, -.connection-card__empty { +.connection-card__empty, +.connection-card__environment-note { display: flex; align-items: flex-start; gap: var(--space-2); @@ -170,21 +280,22 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v flex-wrap: wrap; color: var(--color-text-muted); } +.connection-card__environment-note { + color: var(--color-text-muted); + font-size: var(--text-sm); +} .text-link { color: var(--color-primary); font-weight: 650; } -pre { - max-width: 100%; - margin: 0; - overflow-x: auto; - border-radius: var(--radius-control); - background: var(--color-code-bg); - color: var(--color-code); - padding: var(--space-4); - line-height: 1.6; -} @media (max-width: 480px) { + .connection-card__header { + align-items: stretch; + flex-direction: column; + } + .connection-card__choices { + grid-template-columns: 1fr; + } .connection-card__value { align-items: flex-start; } diff --git a/web/src/features/home/connection-preference.test.ts b/web/src/features/home/connection-preference.test.ts new file mode 100644 index 00000000..9d0111ab --- /dev/null +++ b/web/src/features/home/connection-preference.test.ts @@ -0,0 +1,31 @@ +import { createConnectionPreference } from './connection-preference' + +describe('connection preference', () => { + it('defaults expanded and restores only an explicit collapsed preference', () => { + const values = new Map() + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + } + const first = createConnectionPreference(storage) + expect(first.initialExpanded).toBe(true) + + first.setExpanded(false) + + expect(createConnectionPreference(storage).initialExpanded).toBe(false) + }) + + it('remains expanded and usable when storage is denied', () => { + const preference = createConnectionPreference({ + getItem() { + throw new DOMException('denied') + }, + setItem() { + throw new DOMException('denied') + }, + }) + + expect(preference.initialExpanded).toBe(true) + expect(() => preference.setExpanded(false)).not.toThrow() + }) +}) diff --git a/web/src/features/home/connection-preference.ts b/web/src/features/home/connection-preference.ts new file mode 100644 index 00000000..0cb014d8 --- /dev/null +++ b/web/src/features/home/connection-preference.ts @@ -0,0 +1,41 @@ +const connectionPreferenceKey = 'gpt-load.connection.expanded' + +export interface ConnectionPreferenceStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface ConnectionPreference { + initialExpanded: boolean + setExpanded(expanded: boolean): void +} + +export function createConnectionPreference( + storage?: ConnectionPreferenceStorage, +): ConnectionPreference { + let initialExpanded = true + try { + initialExpanded = storage?.getItem(connectionPreferenceKey) !== 'false' + } catch { + initialExpanded = true + } + + return { + initialExpanded, + setExpanded(expanded) { + try { + storage?.setItem(connectionPreferenceKey, String(expanded)) + } catch { + // The current in-memory choice remains usable when persistence is denied. + } + }, + } +} + +export function resolveConnectionPreferenceStorage(): ConnectionPreferenceStorage | undefined { + try { + return window.localStorage + } catch { + return undefined + } +} diff --git a/web/src/features/home/home-model.test.ts b/web/src/features/home/home-model.test.ts index b107c699..ba4ab763 100644 --- a/web/src/features/home/home-model.test.ts +++ b/web/src/features/home/home-model.test.ts @@ -1,6 +1,7 @@ import type { AccessKeyOptionDto, GroupSummary, KeyCounts } from '@/api/control/types' import { + buildConnectionSnippet, buildChatCompletionsSnippet, isGroupServiceable, isLoopbackHostname, @@ -95,4 +96,41 @@ describe('Home model', () => { `curl "https://gateway.example.com/v1/chat/completions" \\\n -H "Authorization: Bearer $GPT_LOAD_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"gpt-4o"}'`, ) }) + + it.each([ + ['openai', '/v1/chat/completions', 'Authorization: Bearer $GPT_LOAD_API_KEY', undefined], + ['anthropic', '/v1/messages', 'x-api-key: $GPT_LOAD_API_KEY', 'anthropic-version:'], + [ + 'gemini', + '/v1beta/models/gemini-2.5-flash:generateContent', + 'x-goog-api-key: $GPT_LOAD_API_KEY', + undefined, + ], + ] as const)( + 'builds the native %s endpoint and authentication contract', + (protocol, path, authentication, extraHeader) => { + const snippet = buildConnectionSnippet({ + origin: 'https://gateway.example.com/', + protocol, + model: protocol === 'gemini' ? 'gemini-2.5-flash' : 'model-real', + }) + + expect(snippet.path).toBe(path) + expect(snippet.language).toBe('bash') + expect(snippet.command).toContain(`https://gateway.example.com${path}`) + expect(snippet.command).toContain(authentication) + if (extraHeader) expect(snippet.command).toContain(extraHeader) + expect(snippet.command).not.toContain('ACCESS_KEY_CANARY') + }, + ) + + it('keeps an explicit model placeholder readable in the Gemini path', () => { + expect( + buildConnectionSnippet({ + origin: 'https://gateway.example.com', + protocol: 'gemini', + model: '', + }).path, + ).toBe('/v1beta/models/:generateContent') + }) }) diff --git a/web/src/features/home/home-model.ts b/web/src/features/home/home-model.ts index 30da7b3e..32f3cbbd 100644 --- a/web/src/features/home/home-model.ts +++ b/web/src/features/home/home-model.ts @@ -11,6 +11,63 @@ export function buildChatCompletionsSnippet(origin: string, model: string): stri return `curl "${origin}/v1/chat/completions" \\\n -H "Authorization: Bearer $GPT_LOAD_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d ${payload}` } +export type NativeProtocol = 'openai' | 'anthropic' | 'gemini' + +export interface ConnectionSnippetInput { + origin: string + protocol: NativeProtocol + model: string +} + +export interface ConnectionSnippet { + path: string + command: string + language: 'bash' +} + +function normalizedOrigin(origin: string): string { + return origin.replace(/\/+$/, '') +} + +export function buildConnectionSnippet(input: ConnectionSnippetInput): ConnectionSnippet { + const origin = normalizedOrigin(input.origin) + if (input.protocol === 'openai') { + const path = '/v1/chat/completions' + return { + path, + command: buildChatCompletionsSnippet(origin, input.model), + language: 'bash', + } + } + + if (input.protocol === 'anthropic') { + const path = '/v1/messages' + const payload = quotePosixShellArgument( + JSON.stringify({ + model: input.model, + max_tokens: 64, + messages: [{ role: 'user', content: 'Hello' }], + }), + ) + return { + path, + command: `curl "${origin}${path}" \\\n -H "x-api-key: $GPT_LOAD_API_KEY" \\\n -H "anthropic-version: 2023-06-01" \\\n -H "Content-Type: application/json" \\\n -d ${payload}`, + language: 'bash', + } + } + + const modelPath = input.model === '' ? input.model : encodeURIComponent(input.model) + const path = `/v1beta/models/${modelPath}:generateContent` + const payload = quotePosixShellArgument( + JSON.stringify({ contents: [{ parts: [{ text: 'Hello' }] }] }), + ) + return { + path, + command: `curl "${origin}${path}" \\\n -H "x-goog-api-key: $GPT_LOAD_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d ${payload}`, + language: 'bash', + } +} + export function isGroupServiceable(group: GroupSummary, counts?: KeyCounts): boolean | undefined { if (!counts) return undefined return group.enabled && group.models.length > 0 && counts.available > 0 diff --git a/web/src/i18n/locales/en-US/core.ts b/web/src/i18n/locales/en-US/core.ts index 5da38532..197057e9 100644 --- a/web/src/i18n/locales/en-US/core.ts +++ b/web/src/i18n/locales/en-US/core.ts @@ -122,7 +122,14 @@ export default { copyAccessKey: 'Copy AccessKey', manageAccessKeys: 'Manage AccessKeys', modelPlaceholder: 'When no real model is available, replace {model} with an actual model ID.', + protocol: 'Protocol', + model: 'Model', + collapseConnection: 'Collapse', + expandConnection: 'Expand', + environmentKeyHint: + 'Set GPT_LOAD_API_KEY in your shell. The copied command never contains plaintext key material.', snippet: 'Client example', + copySnippet: 'Copy client example', usage: { eyebrow: 'Usage summary', title: 'Last 24 hours', diff --git a/web/src/i18n/locales/ja-JP/core.ts b/web/src/i18n/locales/ja-JP/core.ts index 5604ddd0..48db1e1c 100644 --- a/web/src/i18n/locales/ja-JP/core.ts +++ b/web/src/i18n/locales/ja-JP/core.ts @@ -121,7 +121,14 @@ export default { copyAccessKey: 'AccessKey をコピー', manageAccessKeys: 'AccessKey を管理', modelPlaceholder: '実在モデルがない場合は、{model} を実際のモデル ID に置き換えてください。', + protocol: 'プロトコル', + model: 'モデル', + collapseConnection: '折りたたむ', + expandConnection: '展開', + environmentKeyHint: + 'シェルで GPT_LOAD_API_KEY を設定してください。コピーされるコマンドにキーの平文は含まれません。', snippet: 'クライアント例', + copySnippet: 'クライアント例をコピー', usage: { eyebrow: '使用量サマリー', title: '過去 24 時間', diff --git a/web/src/i18n/locales/zh-CN/core.ts b/web/src/i18n/locales/zh-CN/core.ts index 6d95d5fd..61c7208e 100644 --- a/web/src/i18n/locales/zh-CN/core.ts +++ b/web/src/i18n/locales/zh-CN/core.ts @@ -118,7 +118,13 @@ export default { copyAccessKey: '复制 AccessKey', manageAccessKeys: '管理 AccessKey', modelPlaceholder: '未找到真实模型时,请将 {model} 替换为实际模型 ID。', + protocol: '协议', + model: '模型', + collapseConnection: '收起', + expandConnection: '展开', + environmentKeyHint: '请在终端中设置 GPT_LOAD_API_KEY;复制的命令永远不会包含密钥明文。', snippet: '客户端示例', + copySnippet: '复制客户端示例', usage: { eyebrow: '用量摘要', title: '最近 24 小时',