dsh-oauth-login 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +59 -0
- package/INSTALL.zh.md +45 -0
- package/LICENSE +201 -0
- package/README.en.md +104 -0
- package/README.md +104 -0
- package/README.zh.md +3 -0
- package/SECURITY.md +33 -0
- package/cordis.patch.yml +5 -0
- package/docs/network-proxy.md +45 -0
- package/docs/openrouter-sync.md +84 -0
- package/docs/screenshots/01-providers-signed-out.png +0 -0
- package/docs/screenshots/03-copilot-signing-in.png +0 -0
- package/docs/screenshots/04-mixed-live-states.png +0 -0
- package/docs/screenshots/05-copilot-sign-in.gif +0 -0
- package/docs/screenshots/06-cli-status-store.png +0 -0
- package/lib/bin.d.ts +5 -0
- package/lib/bin.js +2132 -0
- package/lib/client.js +1915 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +482 -0
- package/lib/index.js +3105 -0
- package/lib/invariant.d.ts +7 -0
- package/lib/invariant.js +8 -0
- package/package.json +180 -0
- package/src/adapter.ts +207 -0
- package/src/auth-routes.ts +654 -0
- package/src/auth.ts +76 -0
- package/src/bin.ts +185 -0
- package/src/catalog.ts +129 -0
- package/src/client/OpenRouterSyncStatus.tsx +66 -0
- package/src/client/PiLoginSettings.tsx +470 -0
- package/src/client/ProxySettings.tsx +123 -0
- package/src/client/SearchableModelSelect.tsx +595 -0
- package/src/client/draft-input.ts +12 -0
- package/src/client/index.tsx +71 -0
- package/src/client/locales.ts +176 -0
- package/src/client/login-input-copy.ts +34 -0
- package/src/client/login-window.ts +24 -0
- package/src/client/model-filter.ts +45 -0
- package/src/client/openrouter-store.ts +122 -0
- package/src/client/proxy-draft.ts +57 -0
- package/src/extra-models.ts +201 -0
- package/src/hosted-capture.ts +41 -0
- package/src/hosted-images.ts +154 -0
- package/src/http.ts +241 -0
- package/src/ids.ts +17 -0
- package/src/index.ts +183 -0
- package/src/invariant.ts +12 -0
- package/src/model-error-hint.ts +98 -0
- package/src/native-tools.ts +442 -0
- package/src/oauth-refresh.ts +49 -0
- package/src/openrouter-catalog.ts +245 -0
- package/src/openrouter-free.ts +76 -0
- package/src/openrouter-models.ts +155 -0
- package/src/openrouter-types.ts +40 -0
- package/src/plugin-config.ts +30 -0
- package/src/provider.ts +137 -0
- package/src/proxy-config.ts +61 -0
- package/src/proxy-store.ts +45 -0
- package/src/proxy-transport.ts +220 -0
- package/src/redact.ts +22 -0
- package/src/responses-tap.ts +97 -0
- package/src/session.ts +104 -0
- package/src/store.ts +234 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export const en = {
|
|
2
|
+
nav: 'Subscription Login',
|
|
3
|
+
title: 'Subscription Login',
|
|
4
|
+
loadingAccount: 'Loading accounts…',
|
|
5
|
+
signedOut: 'Not signed in',
|
|
6
|
+
signingIn: 'Waiting for authorization…',
|
|
7
|
+
waitingForCredential: 'Waiting for a Plan API key…',
|
|
8
|
+
waitingForCallback: 'Waiting for the authorization callback…',
|
|
9
|
+
waitingForAuthInput: 'Waiting for authorization details…',
|
|
10
|
+
signedIn: 'Signed in',
|
|
11
|
+
login: 'Sign in',
|
|
12
|
+
connectPlan: 'Connect plan',
|
|
13
|
+
loginAgain: 'Sign in again',
|
|
14
|
+
reopenAuthorization: 'Reopen authorization',
|
|
15
|
+
cancelLogin: 'Cancel',
|
|
16
|
+
logout: 'Sign out',
|
|
17
|
+
working: 'Working…',
|
|
18
|
+
userCode: 'Code',
|
|
19
|
+
openUrl: 'Authorize',
|
|
20
|
+
openPlanPage: 'Plan page',
|
|
21
|
+
credentialHelp: 'Create or copy the provider-specific Plan API key, then paste it here. It stays in DSH’s private credential file.',
|
|
22
|
+
credentialPlaceholder: 'Paste Plan API key',
|
|
23
|
+
saveCredential: 'Save and connect',
|
|
24
|
+
credentialRequired: 'Paste the Plan API key first.',
|
|
25
|
+
callbackHelp: 'Paste the complete callback URL from the browser after authorization. DSH uses it only to finish this sign-in.',
|
|
26
|
+
callbackPlaceholder: 'Paste the callback URL',
|
|
27
|
+
submitCallback: 'Complete sign-in',
|
|
28
|
+
callbackRequired: 'Paste the callback URL first.',
|
|
29
|
+
authInputHelp: 'Enter the value requested by the provider to continue authorization.',
|
|
30
|
+
authInputPlaceholder: 'Enter authorization value',
|
|
31
|
+
submitAuthInput: 'Continue',
|
|
32
|
+
authInputRequired: 'Enter the requested authorization value first.',
|
|
33
|
+
requestFailed: 'The login request failed.',
|
|
34
|
+
proxyTitle: 'Network proxy',
|
|
35
|
+
proxyScope: 'Only applies to requests made by this subscription plugin. Does not change your system or browser proxy.',
|
|
36
|
+
proxyLoading: 'Loading network settings…',
|
|
37
|
+
proxyHttpEnabled: 'Use a proxy for HTTP',
|
|
38
|
+
proxyWsEnabled: 'Use a proxy for WebSocket',
|
|
39
|
+
proxyHttpHelp: 'Login, token refresh, model requests over HTTPS / SSE, and model catalogs.',
|
|
40
|
+
proxyWsHelp: 'Model WebSocket connections. This switch controls proxy routing; it does not disable WebSocket itself.',
|
|
41
|
+
proxyAddress: 'Proxy address (HTTP / HTTPS)',
|
|
42
|
+
proxyPort: 'Port',
|
|
43
|
+
proxyPortPlaceholder: 'e.g. 45678',
|
|
44
|
+
proxyDirect: 'Off: connect directly, ignoring proxy environment variables. The address is kept for later.',
|
|
45
|
+
proxyAuto: 'Default address with no port: keep automatic environment / system proxy discovery; connect directly if none is found. Port 80 is not assumed.',
|
|
46
|
+
proxyExplicit: 'Use this HTTP(S) CONNECT proxy instead of the automatically detected one. SOCKS-only ports are not supported.',
|
|
47
|
+
proxyApplyHelp: 'Saving applies to new requests without interrupting a running call. Sessions already using SSE stay on SSE. Original retry limits are unchanged.',
|
|
48
|
+
proxySave: 'Save network settings',
|
|
49
|
+
proxyReload: 'Reload settings',
|
|
50
|
+
proxySaved: 'Saved. New requests use these settings.',
|
|
51
|
+
proxyLoadFailed: 'Network settings could not be loaded. Reload, or activate the updated plugin backend.',
|
|
52
|
+
proxySaveFailed: 'Could not confirm that network settings were saved. Reload to check their current state.',
|
|
53
|
+
proxyInvalid: 'Enter an HTTP(S) proxy address without credentials or a path, and a port from 1 to 65535. If both fields specify a port, they must match.',
|
|
54
|
+
proxyConflict: 'Another page changed these settings. Reload before saving again.',
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
export type PiLoginKey = keyof typeof en
|
|
58
|
+
|
|
59
|
+
export const zh: { [Key in PiLoginKey]: string } = {
|
|
60
|
+
nav: '订阅登录',
|
|
61
|
+
title: '订阅登录',
|
|
62
|
+
loadingAccount: '正在加载账户…',
|
|
63
|
+
signedOut: '尚未登录',
|
|
64
|
+
signingIn: '正在等待授权…',
|
|
65
|
+
waitingForCredential: '等待输入套餐 API Key…',
|
|
66
|
+
waitingForCallback: '等待输入授权回调地址…',
|
|
67
|
+
waitingForAuthInput: '等待输入授权信息…',
|
|
68
|
+
signedIn: '已登录',
|
|
69
|
+
login: '登录',
|
|
70
|
+
connectPlan: '连接套餐',
|
|
71
|
+
loginAgain: '重新登录',
|
|
72
|
+
reopenAuthorization: '重新打开授权页',
|
|
73
|
+
cancelLogin: '取消',
|
|
74
|
+
logout: '退出',
|
|
75
|
+
working: '处理中…',
|
|
76
|
+
userCode: '授权码',
|
|
77
|
+
openUrl: '授权',
|
|
78
|
+
openPlanPage: '套餐页面',
|
|
79
|
+
credentialHelp: '请在套餐页新建或复制专用 API Key,再粘贴到这里。密钥只保存在 DSH 的私有凭据文件中。',
|
|
80
|
+
credentialPlaceholder: '粘贴套餐 API Key',
|
|
81
|
+
saveCredential: '保存并连接',
|
|
82
|
+
credentialRequired: '请先粘贴套餐 API Key。',
|
|
83
|
+
callbackHelp: '请在浏览器完成授权后,复制完整的回调地址并粘贴到这里。DSH 只用它完成本次登录。',
|
|
84
|
+
callbackPlaceholder: '粘贴授权回调地址',
|
|
85
|
+
submitCallback: '完成登录',
|
|
86
|
+
callbackRequired: '请先粘贴授权回调地址。',
|
|
87
|
+
authInputHelp: '请输入服务商要求的内容以继续授权。',
|
|
88
|
+
authInputPlaceholder: '输入授权信息',
|
|
89
|
+
submitAuthInput: '继续',
|
|
90
|
+
authInputRequired: '请先输入服务商要求的授权信息。',
|
|
91
|
+
requestFailed: '登录请求失败。',
|
|
92
|
+
proxyTitle: '网络代理',
|
|
93
|
+
proxyScope: '仅影响本订阅插件发出的请求,不修改系统或浏览器代理。',
|
|
94
|
+
proxyLoading: '正在加载网络设置…',
|
|
95
|
+
proxyHttpEnabled: 'HTTP 使用代理',
|
|
96
|
+
proxyWsEnabled: 'WebSocket 使用代理',
|
|
97
|
+
proxyHttpHelp: '用于登录、令牌刷新、HTTPS / SSE 模型请求和模型列表。',
|
|
98
|
+
proxyWsHelp: '用于模型的 WebSocket 连接。此开关只控制是否走代理,不关闭 WebSocket 本身。',
|
|
99
|
+
proxyAddress: '代理地址(HTTP / HTTPS)',
|
|
100
|
+
proxyPort: '端口',
|
|
101
|
+
proxyPortPlaceholder: '例如 45678',
|
|
102
|
+
proxyDirect: '已关闭:直接连接,忽略代理环境变量。保留地址,方便下次开启。',
|
|
103
|
+
proxyAuto: '默认地址、端口留空:沿用环境变量、系统代理等自动发现;没有找到代理时直连,不会默认使用 80 端口。',
|
|
104
|
+
proxyExplicit: '优先使用此 HTTP(S) CONNECT 代理,不使用自动发现的地址。不支持纯 SOCKS 端口。',
|
|
105
|
+
proxyApplyHelp: '保存后用于新请求,不中断正在进行的调用。已降级的会话继续使用 SSE,原有重试上限不变。',
|
|
106
|
+
proxySave: '保存网络设置',
|
|
107
|
+
proxyReload: '重新加载',
|
|
108
|
+
proxySaved: '已保存,新请求将使用这些设置。',
|
|
109
|
+
proxyLoadFailed: '网络设置加载失败。请重新加载,或先启用更新后的插件服务端。',
|
|
110
|
+
proxySaveFailed: '未能确认网络设置已保存,请重新加载后检查当前设置。',
|
|
111
|
+
proxyInvalid: '请填写不含账号密码或路径的 HTTP(S) 代理地址,端口为 1–65535。若地址中也有端口,须与端口栏一致。',
|
|
112
|
+
proxyConflict: '其他页面已修改设置,请重新加载后再保存。',
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `model-search` namespace: copy for the searchable composer model seat. */
|
|
116
|
+
export const searchEn = {
|
|
117
|
+
placeholder: 'Search models…',
|
|
118
|
+
aria: 'Filter models',
|
|
119
|
+
empty: 'No models match “{query}”.',
|
|
120
|
+
all: 'All',
|
|
121
|
+
freeOnly: 'Free only',
|
|
122
|
+
freeFilterAria: 'Model price filter (OpenRouter)',
|
|
123
|
+
free: 'Free · Rate limited',
|
|
124
|
+
freeCached: 'Free (cached) · Recheck pending',
|
|
125
|
+
freeEnded: 'Free offer ended · Paid calls blocked',
|
|
126
|
+
freeUnknown: 'Price unconfirmed · Free guard active',
|
|
127
|
+
tools: 'Tool calling',
|
|
128
|
+
chatOnly: 'Chat only',
|
|
129
|
+
context: '{tokens} context',
|
|
130
|
+
emptyFree: 'No matching free OpenRouter models. Clear the search or refresh the catalog.',
|
|
131
|
+
syncSchedule: 'OpenRouter · Sync every 15 min',
|
|
132
|
+
syncing: 'Syncing models…',
|
|
133
|
+
syncSignIn: 'Sign in to OpenRouter to sync models.',
|
|
134
|
+
syncRefresh: 'Refresh models',
|
|
135
|
+
syncCooldown: 'Refreshed within 60s',
|
|
136
|
+
syncUpdated: 'Last synced',
|
|
137
|
+
syncStale: 'Cached · Refresh needed',
|
|
138
|
+
syncBuiltin: 'Not synced yet; using built-in models.',
|
|
139
|
+
syncError: 'Sync failed. Keeping the previous list; retry later.',
|
|
140
|
+
syncSaveError: 'Could not save the safe catalog cache. Keeping the previous list; check DSH disk permissions.',
|
|
141
|
+
syncRestart: 'Restart DSH to activate the new model-sync backend.',
|
|
142
|
+
freeHelp: 'Free models have rate and daily limits. Unavailable models never fall back to paid ones.',
|
|
143
|
+
forcedPluginWarning: 'Disable account-enforced paid OpenRouter plugins: DSH cannot override platform-enforced charges.',
|
|
144
|
+
} as const
|
|
145
|
+
|
|
146
|
+
export type ModelSearchKey = keyof typeof searchEn
|
|
147
|
+
|
|
148
|
+
export const searchZh: { [Key in ModelSearchKey]: string } = {
|
|
149
|
+
placeholder: '搜索模型…',
|
|
150
|
+
aria: '筛选模型',
|
|
151
|
+
empty: '没有匹配“{query}”的模型。',
|
|
152
|
+
all: '全部',
|
|
153
|
+
freeOnly: '仅免费',
|
|
154
|
+
freeFilterAria: '模型价格筛选(OpenRouter)',
|
|
155
|
+
free: '免费 · 有限额',
|
|
156
|
+
freeCached: '免费缓存 · 待更新',
|
|
157
|
+
freeEnded: '免费已结束 · 付费已拦截',
|
|
158
|
+
freeUnknown: '价格未确认 · 免费保护中',
|
|
159
|
+
tools: '支持工具调用',
|
|
160
|
+
chatOnly: '仅聊天',
|
|
161
|
+
context: '{tokens} 上下文',
|
|
162
|
+
emptyFree: '没有匹配的 OpenRouter 免费模型。请清空搜索或刷新列表。',
|
|
163
|
+
syncSchedule: 'OpenRouter · 每 15 分钟同步',
|
|
164
|
+
syncing: '正在同步模型…',
|
|
165
|
+
syncSignIn: '登录 OpenRouter 后自动同步模型。',
|
|
166
|
+
syncRefresh: '刷新模型',
|
|
167
|
+
syncCooldown: '60 秒内已刷新',
|
|
168
|
+
syncUpdated: '上次同步',
|
|
169
|
+
syncStale: '缓存待更新',
|
|
170
|
+
syncBuiltin: '尚未同步,暂用内置模型列表。',
|
|
171
|
+
syncError: '同步失败,保留上次列表;请稍后重试。',
|
|
172
|
+
syncSaveError: '安全缓存保存失败,保留上次列表;请检查 DSH 目录写入权限。',
|
|
173
|
+
syncRestart: '请重启 DSH,启用新的模型同步服务。',
|
|
174
|
+
freeHelp: '免费模型有速率和每日限额;不可用时不会切换到付费模型。',
|
|
175
|
+
forcedPluginWarning: '请关闭 OpenRouter 账户强制启用的收费插件;平台强制规则不能由 DSH 覆盖。',
|
|
176
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PiLoginKey } from './locales.ts'
|
|
2
|
+
|
|
3
|
+
export type LoginAuthType = 'oauth' | 'api_key'
|
|
4
|
+
export type LoginChallengeType = 'secret' | 'text' | 'manual_code'
|
|
5
|
+
|
|
6
|
+
export interface LoginInputCopy {
|
|
7
|
+
waiting: PiLoginKey
|
|
8
|
+
help: PiLoginKey
|
|
9
|
+
placeholder: PiLoginKey
|
|
10
|
+
action: PiLoginKey
|
|
11
|
+
required: PiLoginKey
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PLAN_COPY: LoginInputCopy = {
|
|
15
|
+
waiting: 'waitingForCredential', help: 'credentialHelp', placeholder: 'credentialPlaceholder',
|
|
16
|
+
action: 'saveCredential', required: 'credentialRequired',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CALLBACK_COPY: LoginInputCopy = {
|
|
20
|
+
waiting: 'waitingForCallback', help: 'callbackHelp', placeholder: 'callbackPlaceholder',
|
|
21
|
+
action: 'submitCallback', required: 'callbackRequired',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AUTH_INPUT_COPY: LoginInputCopy = {
|
|
25
|
+
waiting: 'waitingForAuthInput', help: 'authInputHelp', placeholder: 'authInputPlaceholder',
|
|
26
|
+
action: 'submitAuthInput', required: 'authInputRequired',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Select visible copy from the provider contract and the actual Pi challenge. */
|
|
30
|
+
export function loginInputCopy(authType: LoginAuthType, challengeType: LoginChallengeType): LoginInputCopy {
|
|
31
|
+
if (authType === 'api_key') return PLAN_COPY
|
|
32
|
+
if (challengeType === 'manual_code') return CALLBACK_COPY
|
|
33
|
+
return AUTH_INPUT_COPY
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface LoginPopup {
|
|
2
|
+
close(): void
|
|
3
|
+
location: { replace(url: string): void }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type LoginUrlOpener = (url: string) => void
|
|
7
|
+
|
|
8
|
+
/** Deliver a login challenge even when an Electron shell rejects the reserved about:blank popup. */
|
|
9
|
+
export function openLoginChallenge(
|
|
10
|
+
popup: LoginPopup | null,
|
|
11
|
+
url: string | undefined,
|
|
12
|
+
openUrl: LoginUrlOpener,
|
|
13
|
+
): 'reserved' | 'fallback' | 'none' {
|
|
14
|
+
if (url === undefined) {
|
|
15
|
+
popup?.close()
|
|
16
|
+
return 'none'
|
|
17
|
+
}
|
|
18
|
+
if (popup !== null) {
|
|
19
|
+
popup.location.replace(url)
|
|
20
|
+
return 'reserved'
|
|
21
|
+
}
|
|
22
|
+
openUrl(url)
|
|
23
|
+
return 'fallback'
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure filtering for the searchable composer model seat. Kept free of React
|
|
3
|
+
* so the unit suite can exercise it without a DOM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Minimal structural mirror of the host model catalog row (no runtime import). */
|
|
7
|
+
export interface FilterModel {
|
|
8
|
+
id: string
|
|
9
|
+
name: string
|
|
10
|
+
description?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Minimal structural mirror of one provider group. */
|
|
14
|
+
export interface FilterGroup {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
models: readonly FilterModel[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Case-insensitive substring filter over model name, model id, and provider
|
|
22
|
+
* name. Blank queries return the input unchanged (same array identity).
|
|
23
|
+
* Groups whose every model is filtered out disappear; relative order holds.
|
|
24
|
+
* @param groups - provider groups from the shared directory snapshot.
|
|
25
|
+
* @param query - raw user text; trimmed and lowercased here.
|
|
26
|
+
* @returns the filtered groups.
|
|
27
|
+
*/
|
|
28
|
+
export function filterGroups(
|
|
29
|
+
groups: readonly FilterGroup[],
|
|
30
|
+
query: string,
|
|
31
|
+
freeModels?: ReadonlySet<string>,
|
|
32
|
+
): readonly FilterGroup[] {
|
|
33
|
+
const q = query.trim().toLowerCase()
|
|
34
|
+
if (q === '' && freeModels === undefined) return groups
|
|
35
|
+
const out: FilterGroup[] = []
|
|
36
|
+
for (const group of groups) {
|
|
37
|
+
if (freeModels !== undefined && group.id !== 'pi-openrouter') continue
|
|
38
|
+
const groupHit = group.name.toLowerCase().includes(q)
|
|
39
|
+
const matches = groupHit ? group.models : group.models.filter(model =>
|
|
40
|
+
model.name.toLowerCase().includes(q) || model.id.toLowerCase().includes(q))
|
|
41
|
+
const models = freeModels === undefined ? matches : matches.filter(model => freeModels.has(model.id))
|
|
42
|
+
if (models.length > 0) out.push({ ...group, models })
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { OPENROUTER_CATALOG_PATH, OPENROUTER_REFRESH_PATH } from '../openrouter-types.ts'
|
|
2
|
+
import type { OpenRouterCatalogSnapshot } from '../openrouter-types.ts'
|
|
3
|
+
|
|
4
|
+
export interface OpenRouterClientState {
|
|
5
|
+
data: OpenRouterCatalogSnapshot | null
|
|
6
|
+
loading: boolean
|
|
7
|
+
error: 'unavailable' | 'restart' | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function validSnapshot(value: unknown): value is OpenRouterCatalogSnapshot {
|
|
11
|
+
if (typeof value !== 'object' || value === null) return false
|
|
12
|
+
const data = value as Record<string, unknown>
|
|
13
|
+
const timestamp = (entry: unknown) => entry === null
|
|
14
|
+
|| typeof entry === 'number' && Number.isFinite(entry) && entry > 0 && entry <= 8.64e15
|
|
15
|
+
if (data.version !== 1 || typeof data.connected !== 'boolean'
|
|
16
|
+
|| typeof data.refreshing !== 'boolean' || typeof data.stale !== 'boolean'
|
|
17
|
+
|| !timestamp(data.lastUpdatedAt) || !timestamp(data.lastAttemptAt)
|
|
18
|
+
|| !timestamp(data.nextRefreshAt) || !timestamp(data.retryAt)
|
|
19
|
+
|| !Array.isArray(data.models) || data.models.length > 10_000) return false
|
|
20
|
+
return data.models.every((entry: unknown) => {
|
|
21
|
+
if (typeof entry !== 'object' || entry === null) return false
|
|
22
|
+
const model = entry as Record<string, unknown>
|
|
23
|
+
return typeof model.id === 'string' && typeof model.name === 'string'
|
|
24
|
+
&& typeof model.tools === 'boolean' && typeof model.freeOnly === 'boolean'
|
|
25
|
+
&& typeof model.contextWindow === 'number' && Number.isFinite(model.contextWindow)
|
|
26
|
+
&& ['free', 'paid', 'unknown'].includes(String(model.priceStatus))
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One metadata store/poll per browser plugin, shared by settings and menus. */
|
|
31
|
+
export class OpenRouterCatalogClient {
|
|
32
|
+
private state: OpenRouterClientState = { data: null, loading: false, error: null }
|
|
33
|
+
private readonly listeners = new Set<() => void>()
|
|
34
|
+
private pending: Promise<void> | undefined
|
|
35
|
+
private controller: AbortController | undefined
|
|
36
|
+
private poll: ReturnType<typeof setTimeout> | undefined
|
|
37
|
+
private lastRead = -Infinity
|
|
38
|
+
private disposed = false
|
|
39
|
+
|
|
40
|
+
constructor(private readonly request: typeof globalThis.fetch = (...args) => globalThis.fetch(...args)) {}
|
|
41
|
+
|
|
42
|
+
getSnapshot = (): OpenRouterClientState => this.state
|
|
43
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
44
|
+
this.listeners.add(listener)
|
|
45
|
+
if (this.listeners.size === 1 && !this.disposed) void this.load()
|
|
46
|
+
return () => {
|
|
47
|
+
this.listeners.delete(listener)
|
|
48
|
+
if (this.listeners.size === 0) {
|
|
49
|
+
clearTimeout(this.poll)
|
|
50
|
+
this.poll = undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
private update(state: OpenRouterClientState): void {
|
|
55
|
+
this.state = state
|
|
56
|
+
for (const listener of this.listeners) listener()
|
|
57
|
+
}
|
|
58
|
+
private failed(error: OpenRouterClientState['error']): void {
|
|
59
|
+
if (this.disposed) return
|
|
60
|
+
this.update({ ...this.state, error,
|
|
61
|
+
data: this.state.data === null ? null : { ...this.state.data, stale: true },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
load = (force = false): Promise<void> => {
|
|
66
|
+
if (this.disposed) return Promise.resolve()
|
|
67
|
+
if (this.pending !== undefined) return this.pending
|
|
68
|
+
if (!force && Date.now() - this.lastRead < 1500) {
|
|
69
|
+
this.schedule()
|
|
70
|
+
return Promise.resolve()
|
|
71
|
+
}
|
|
72
|
+
this.lastRead = Date.now()
|
|
73
|
+
clearTimeout(this.poll)
|
|
74
|
+
this.poll = undefined
|
|
75
|
+
this.controller = new AbortController()
|
|
76
|
+
const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(20_000)])
|
|
77
|
+
this.update({ ...this.state, loading: true, error: null })
|
|
78
|
+
const pending = this.read(force, signal).finally(() => {
|
|
79
|
+
if (this.pending !== pending) return
|
|
80
|
+
this.pending = undefined
|
|
81
|
+
this.controller = undefined
|
|
82
|
+
if (!this.disposed) {
|
|
83
|
+
this.update({ ...this.state, loading: false })
|
|
84
|
+
this.schedule()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
this.pending = pending
|
|
88
|
+
return pending
|
|
89
|
+
}
|
|
90
|
+
private async read(force: boolean, signal: AbortSignal): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
const response = await this.request(force ? OPENROUTER_REFRESH_PATH : OPENROUTER_CATALOG_PATH, {
|
|
93
|
+
method: force ? 'POST' : 'GET', credentials: 'same-origin',
|
|
94
|
+
headers: { accept: 'application/json' }, signal,
|
|
95
|
+
})
|
|
96
|
+
if (response.status === 404) {
|
|
97
|
+
this.failed('restart')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) throw new Error('catalog unavailable')
|
|
101
|
+
const data: unknown = await response.json()
|
|
102
|
+
if (!validSnapshot(data)) {
|
|
103
|
+
throw new Error('invalid catalog response')
|
|
104
|
+
}
|
|
105
|
+
if (!this.disposed) this.update({ ...this.state, data, error: null })
|
|
106
|
+
} catch {
|
|
107
|
+
this.failed('unavailable')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
private schedule(): void {
|
|
111
|
+
if (this.disposed || this.listeners.size === 0) return
|
|
112
|
+
clearTimeout(this.poll)
|
|
113
|
+
// This is a local metadata read, NOT an upstream model refresh.
|
|
114
|
+
this.poll = setTimeout(() => { void this.load() }, this.state.data?.refreshing ? 2000 : 60_000)
|
|
115
|
+
}
|
|
116
|
+
dispose(): void {
|
|
117
|
+
this.disposed = true
|
|
118
|
+
clearTimeout(this.poll)
|
|
119
|
+
this.controller?.abort()
|
|
120
|
+
this.listeners.clear()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parseProxySettings } from '../proxy-config.ts'
|
|
2
|
+
import type { ProxyChannelSettings, ProxySettingsSnapshot } from '../proxy-config.ts'
|
|
3
|
+
|
|
4
|
+
export interface ProxyChannelDraft {
|
|
5
|
+
enabled: boolean
|
|
6
|
+
address: string
|
|
7
|
+
port: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ProxyDraft = Record<'http' | 'websocket', ProxyChannelDraft>
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_PROXY_ADDRESS = 'http://127.0.0.1'
|
|
13
|
+
|
|
14
|
+
export function isAutomaticProxyDraft({ address, port }: ProxyChannelDraft): boolean {
|
|
15
|
+
const host = address.trim().replace(/\/$/, '')
|
|
16
|
+
return port.trim() === '' && (host === '' || host === DEFAULT_PROXY_ADDRESS || host === '127.0.0.1')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function proxyDraft(settings: ProxySettingsSnapshot): ProxyDraft {
|
|
20
|
+
const channel = ({ enabled, url }: ProxyChannelSettings): ProxyChannelDraft => {
|
|
21
|
+
if (url === '') return { enabled, address: DEFAULT_PROXY_ADDRESS, port: '' }
|
|
22
|
+
const parsed = new URL(url)
|
|
23
|
+
return {
|
|
24
|
+
enabled,
|
|
25
|
+
address: `${parsed.protocol}//${parsed.hostname}`,
|
|
26
|
+
port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80'),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { http: channel(settings.http), websocket: channel(settings.websocket) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function proxyDraftSettings(draft: ProxyDraft, revision: number): ProxySettingsSnapshot {
|
|
33
|
+
const channel = (value: ProxyChannelDraft): ProxyChannelSettings => {
|
|
34
|
+
const { enabled, address, port } = value
|
|
35
|
+
const host = address.trim()
|
|
36
|
+
const portText = port.trim()
|
|
37
|
+
if (isAutomaticProxyDraft(value)) return { enabled, url: '' }
|
|
38
|
+
if (host === '' || (portText !== '' && (!/^\d{1,5}$/.test(portText)
|
|
39
|
+
|| Number(portText) < 1 || Number(portText) > 65535))) {
|
|
40
|
+
throw new Error('Enter a proxy address and a port from 1 to 65535')
|
|
41
|
+
}
|
|
42
|
+
const parsed = new URL(host.includes('://') ? host : `http://${host}`)
|
|
43
|
+
// URL.port omits explicit default ports (80/443), so retain the pasted port.
|
|
44
|
+
const pastedPort = /:(\d+)\/?$/.exec(host)?.[1] ?? ''
|
|
45
|
+
if (portText === '' && pastedPort === '') {
|
|
46
|
+
throw new Error('Enter a proxy port from 1 to 65535')
|
|
47
|
+
}
|
|
48
|
+
if (portText !== '') {
|
|
49
|
+
if (pastedPort !== '' && Number(pastedPort) !== Number(portText)) {
|
|
50
|
+
throw new Error('The address and port fields specify different ports')
|
|
51
|
+
}
|
|
52
|
+
parsed.port = portText
|
|
53
|
+
}
|
|
54
|
+
return { enabled, url: parsed.href }
|
|
55
|
+
}
|
|
56
|
+
return parseProxySettings({ revision, http: channel(draft.http), websocket: channel(draft.websocket) })
|
|
57
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog models pi-ai has not shipped yet, but DSH OAuth routes already need.
|
|
3
|
+
* Entries are merged on top of the installed pi-ai provider catalog.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* https://developers.openai.com/api/docs/models/gpt-6-astra
|
|
10
|
+
* Use the Codex catalog's default 272K context, not the API's larger ceiling.
|
|
11
|
+
* Ultra is an app orchestration mode, not a Pi reasoning effort.
|
|
12
|
+
*/
|
|
13
|
+
const OPENAI_CODEX_EXTRA_MODELS: readonly Model<Api>[] = [
|
|
14
|
+
{
|
|
15
|
+
id: 'gpt-6-astra',
|
|
16
|
+
name: 'GPT-6 Astra',
|
|
17
|
+
api: 'openai-codex-responses',
|
|
18
|
+
provider: 'openai-codex',
|
|
19
|
+
baseUrl: 'https://chatgpt.com/backend-api',
|
|
20
|
+
reasoning: true,
|
|
21
|
+
input: ['text', 'image'],
|
|
22
|
+
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
|
23
|
+
contextWindow: 272_000,
|
|
24
|
+
maxTokens: 128_000,
|
|
25
|
+
thinkingLevelMap: {
|
|
26
|
+
off: null,
|
|
27
|
+
minimal: null,
|
|
28
|
+
low: 'low',
|
|
29
|
+
medium: 'medium',
|
|
30
|
+
high: 'high',
|
|
31
|
+
xhigh: 'xhigh',
|
|
32
|
+
max: 'max',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/** Newer xAI Grok models missing from the installed pi-ai catalog (0.82.x). */
|
|
38
|
+
const XAI_EXTRA_MODELS: readonly Model<Api>[] = [
|
|
39
|
+
{
|
|
40
|
+
id: 'grok-4.6',
|
|
41
|
+
name: 'Grok 4.6',
|
|
42
|
+
api: 'openai-responses',
|
|
43
|
+
provider: 'xai',
|
|
44
|
+
baseUrl: 'https://api.x.ai/v1',
|
|
45
|
+
reasoning: true,
|
|
46
|
+
input: ['text', 'image'],
|
|
47
|
+
cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 },
|
|
48
|
+
contextWindow: 500_000,
|
|
49
|
+
maxTokens: 500_000,
|
|
50
|
+
compat: { supportsLongCacheRetention: false },
|
|
51
|
+
thinkingLevelMap: {
|
|
52
|
+
off: null,
|
|
53
|
+
minimal: null,
|
|
54
|
+
low: 'low',
|
|
55
|
+
medium: 'medium',
|
|
56
|
+
high: 'high',
|
|
57
|
+
xhigh: 'xhigh',
|
|
58
|
+
max: null,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
/** Latest Coding Plan models missing from the installed pi-ai catalog (0.82.x). */
|
|
64
|
+
export const GLM_5_3_MODEL_ID = 'glm-5.3'
|
|
65
|
+
export const GLM_5_3_DEFAULT_EFFORT = 'max'
|
|
66
|
+
export const GLM_5_3_FLASH_MODEL_ID = 'glm-5.3-flash'
|
|
67
|
+
export const GLM_5_3_FLASH_DEFAULT_EFFORT = 'max'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Official GLM-5.3 family metadata. Standard is text-only; Flash also accepts
|
|
71
|
+
* images. Keep separate IDs so choosing standard never routes to Flash.
|
|
72
|
+
* https://docs.bigmodel.cn/cn/guide/models/text/glm-5.3
|
|
73
|
+
* https://docs.bigmodel.cn/cn/coding-plan/latest-model
|
|
74
|
+
*/
|
|
75
|
+
const ZAI_CODING_CN_EXTRA_MODELS: readonly Model<Api>[] = [
|
|
76
|
+
{
|
|
77
|
+
id: GLM_5_3_FLASH_MODEL_ID,
|
|
78
|
+
name: 'GLM-5.3-Flash',
|
|
79
|
+
api: 'openai-completions',
|
|
80
|
+
provider: 'zai-coding-cn',
|
|
81
|
+
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
|
82
|
+
reasoning: true,
|
|
83
|
+
input: ['text', 'image'],
|
|
84
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
85
|
+
contextWindow: 1_000_000,
|
|
86
|
+
maxTokens: 131_072,
|
|
87
|
+
compat: {
|
|
88
|
+
supportsStore: false,
|
|
89
|
+
supportsDeveloperRole: false,
|
|
90
|
+
supportsReasoningEffort: true,
|
|
91
|
+
thinkingFormat: 'zai',
|
|
92
|
+
zaiToolStream: true,
|
|
93
|
+
},
|
|
94
|
+
thinkingLevelMap: {
|
|
95
|
+
off: null,
|
|
96
|
+
minimal: null,
|
|
97
|
+
low: 'low',
|
|
98
|
+
medium: null,
|
|
99
|
+
high: 'high',
|
|
100
|
+
xhigh: null,
|
|
101
|
+
max: 'max',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: GLM_5_3_MODEL_ID,
|
|
106
|
+
name: 'GLM-5.3',
|
|
107
|
+
api: 'openai-completions',
|
|
108
|
+
provider: 'zai-coding-cn',
|
|
109
|
+
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
|
110
|
+
reasoning: true,
|
|
111
|
+
input: ['text'],
|
|
112
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
113
|
+
contextWindow: 1_000_000,
|
|
114
|
+
maxTokens: 131_072,
|
|
115
|
+
compat: {
|
|
116
|
+
supportsStore: false,
|
|
117
|
+
supportsDeveloperRole: false,
|
|
118
|
+
supportsReasoningEffort: true,
|
|
119
|
+
thinkingFormat: 'zai',
|
|
120
|
+
zaiToolStream: true,
|
|
121
|
+
},
|
|
122
|
+
thinkingLevelMap: {
|
|
123
|
+
off: null,
|
|
124
|
+
minimal: null,
|
|
125
|
+
low: 'low',
|
|
126
|
+
medium: null,
|
|
127
|
+
high: 'high',
|
|
128
|
+
xhigh: null,
|
|
129
|
+
max: 'max',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
/** OpenRouter stealth model missing from the installed pi-ai catalog (0.82.x–0.84.x). */
|
|
135
|
+
export const OX_ALPHA_MODEL_ID = 'stealth/ox-alpha'
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* OpenRouter's live default for ox-alpha (`reasoning.default_effort`).
|
|
139
|
+
* Must be sent explicitly: pi-ai's OpenRouter dialect writes
|
|
140
|
+
* `reasoning: { effort: thinkingLevelMap.off ?? "none" }` when the caller
|
|
141
|
+
* omits an effort, and ox-alpha rejects `none` (`reasoning.mandatory: true`).
|
|
142
|
+
*/
|
|
143
|
+
export const OX_ALPHA_DEFAULT_EFFORT = 'max'
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* OpenRouter stealth models missing from the installed pi-ai catalog (0.82.x).
|
|
147
|
+
* Capacities follow the public OpenRouter listing: 1M context, text+image, free.
|
|
148
|
+
* Effort levels follow live `/api/v1/models` (`supported_efforts`, `default_effort`).
|
|
149
|
+
*/
|
|
150
|
+
const OPENROUTER_EXTRA_MODELS: readonly Model<Api>[] = [
|
|
151
|
+
{
|
|
152
|
+
id: OX_ALPHA_MODEL_ID,
|
|
153
|
+
name: 'OX Alpha',
|
|
154
|
+
api: 'openai-completions',
|
|
155
|
+
provider: 'openrouter',
|
|
156
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
157
|
+
reasoning: true,
|
|
158
|
+
input: ['text', 'image'],
|
|
159
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
160
|
+
contextWindow: 1_000_000,
|
|
161
|
+
maxTokens: 131_072,
|
|
162
|
+
compat: { supportsDeveloperRole: false, thinkingFormat: 'openrouter' },
|
|
163
|
+
// Absent keys default to "supported" for off/minimal/low/medium/high and
|
|
164
|
+
// "unsupported" for max — the opposite of this model's live metadata.
|
|
165
|
+
thinkingLevelMap: {
|
|
166
|
+
off: null,
|
|
167
|
+
minimal: null,
|
|
168
|
+
low: 'low',
|
|
169
|
+
medium: null,
|
|
170
|
+
high: 'high',
|
|
171
|
+
xhigh: null,
|
|
172
|
+
max: 'max',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
/** Per-model default the selector and request path should apply when omitted. */
|
|
178
|
+
export function defaultReasoningEffortFor(
|
|
179
|
+
modelId: string,
|
|
180
|
+
): typeof OX_ALPHA_DEFAULT_EFFORT | typeof GLM_5_3_DEFAULT_EFFORT | typeof GLM_5_3_FLASH_DEFAULT_EFFORT | 'medium' | undefined {
|
|
181
|
+
if (modelId === 'gpt-6-astra') return 'medium'
|
|
182
|
+
if (modelId === OX_ALPHA_MODEL_ID) return OX_ALPHA_DEFAULT_EFFORT
|
|
183
|
+
if (modelId === GLM_5_3_MODEL_ID) return GLM_5_3_DEFAULT_EFFORT
|
|
184
|
+
if (modelId === GLM_5_3_FLASH_MODEL_ID) return GLM_5_3_FLASH_DEFAULT_EFFORT
|
|
185
|
+
return undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const EXTRA_MODELS_BY_PROVIDER: Readonly<Record<string, readonly Model<Api>[]>> = {
|
|
189
|
+
'openai-codex': OPENAI_CODEX_EXTRA_MODELS,
|
|
190
|
+
xai: XAI_EXTRA_MODELS,
|
|
191
|
+
openrouter: OPENROUTER_EXTRA_MODELS,
|
|
192
|
+
'zai-coding-cn': ZAI_CODING_CN_EXTRA_MODELS,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extra models this plugin publishes for one pi-ai provider id.
|
|
197
|
+
* @param providerId - catalog provider id (e.g. `xai`), not the harness route.
|
|
198
|
+
*/
|
|
199
|
+
export function extraModelsFor(providerId: string): readonly Model<Api>[] {
|
|
200
|
+
return EXTRA_MODELS_BY_PROVIDER[providerId] ?? []
|
|
201
|
+
}
|