+
+
{{ t('home.baseUrl') }}
- {{ selectedKey.name }}
-
- {{ t('home.manageAccessKeys') }}
-
+ {{ origin }}
+
-
-
-
-
{{ t('home.noAccessKey') }}
-
{{ t('home.createAccessKey') }}
+
+ {{ t('home.loopbackWarning') }}
+
-
-
-
{{ t('home.snippet') }}
-
{{ snippet }}
-
- {{ t('home.modelPlaceholder', { model: modelPlaceholder }) }}
-
+
+
{{ t('home.accessKey') }}
+
+
+
+ {{ selectedKey.name }}
+
+ {{ t('home.manageAccessKeys') }}
+
+
+
+
+
+ {{ t('home.noAccessKey') }}
+ {{
+ t('home.createAccessKey')
+ }}
+
+
+
+
+
+
+
+
+
+
{{ t('home.snippet') }}
+
{{ t('home.environmentKeyHint') }}
+
+
+ {{ t('home.modelPlaceholder', { model: modelPlaceholder }) }}
+
+
@@ -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 小时',