@thebtf/dsh-cliproxyapi-provider 0.0.0-stage → 0.4.3

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/client.js ADDED
@@ -0,0 +1,517 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@thebtf/dsh-cliproxyapi-provider',
3
+ factory: (require) => {
4
+ var module = { exports: {} }
5
+ var exports = module.exports
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
7
+
8
+ const React = require('react')
9
+ const { Button, Input, Checkbox, Tag, StateDot } = require('@deepseek-ai/dsh-client-ui-primitives')
10
+ const { useEffect, useState } = React
11
+
12
+ const DISCOVERY_NS = 'llm-cliproxyapi'
13
+ const SETTINGS_SLOT = 'plugins.item'
14
+ const SETTINGS_TAB_ID = 'cliproxyapi'
15
+ const SETTINGS_LOCALE_NS = 'settings.cliProxyApi'
16
+ const SETTINGS_SUMMARY_NS = 'llm-cliproxyapi'
17
+ const inject = ['slots', 'locale', 'configForms', 'pluginNavigation']
18
+ // Presentation order; the first entry is the default for a new connection.
19
+ const PROTOCOLS = ['openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai']
20
+ const protocolKeys = {
21
+ 'openai-responses': 'protocolResponses',
22
+ 'openai-completions': 'protocolCompletions',
23
+ 'anthropic-messages': 'protocolAnthropic',
24
+ 'google-generative-ai': 'protocolGoogle',
25
+ }
26
+
27
+ const copy = {
28
+ en: {
29
+ tab: 'CLIProxyAPI',
30
+ title: 'CLIProxyAPI',
31
+ intro: 'Connect a CLIProxyAPI server and import its model catalog.',
32
+ imageCapability: 'Image input is enabled automatically for models that advertise image support.',
33
+ loading: 'Loading CLIProxyAPI settings…',
34
+ unavailable: 'CLIProxyAPI settings are unavailable in this Web profile.',
35
+ readOnly: 'Settings are read-only for this connection.',
36
+ connection: 'Connection',
37
+ actions: 'Actions',
38
+ baseURL: 'Base URL',
39
+ apiKey: 'API key',
40
+ apiKeyPlaceholder: 'Optional for a keyless CLIProxyAPI server',
41
+ apiKeyConfiguredPlaceholder: 'API key already saved',
42
+ credentialConfiguredLabel: 'Configured',
43
+ save: 'Save & Enable',
44
+ saving: 'Saving…',
45
+ saved: 'Saved. The CLIProxyAPI model catalog is synchronized.',
46
+ syncTimeout: 'The profile was saved, but its image and reasoning capabilities have not synchronized. Check the provider refresh logs and try again.',
47
+ saveFailed: 'Could not save the CLIProxyAPI profile. Reload settings and try again.',
48
+ baseRequired: 'Base URL is required.',
49
+ baseInvalid: 'Base URL must be a valid HTTP or HTTPS URL.',
50
+ noModels: 'CLIProxyAPI returned no usable models.',
51
+ models: 'Available models',
52
+ loadModels: 'Load models',
53
+ loadingModels: 'Loading models…',
54
+ modelsHelp: 'Disabled models are removed from DSH and requests from existing DSH sessions may fail. Direct CPA API calls are not blocked. New models are enabled by default.',
55
+ loadFailed: 'Could not load the model catalog. Check the URL and API key and try again.',
56
+ metadataHelp: 'Context, output limits and input types are effective settings; values may use configured defaults when the server does not provide them.',
57
+ noEnabledRefresh: 'No current catalog models are enabled. The previous provider list remains active until you enable a model and save.',
58
+ needOneModel: 'Enable at least one available model. DSH cannot save an empty provider catalog.',
59
+ selectionUnavailable: 'Model selection settings are unavailable or read-only.',
60
+ partialSave: 'Saving did not finish. The model selection, API key or profile may already have changed; reload settings and retry.',
61
+ search: 'Search models by name or ID', selectedCount: 'selected of', selectAll: 'Select all loaded models', deselectAll: 'Deselect all loaded models',
62
+ noMatches: 'No models match your search.', enabled: 'Enable', details: 'Model details', rawDetails: 'All CPA fields',
63
+ context: 'Context window', output: 'Maximum output', modalities: 'Input modalities', sourceDefault: 'default', sourceCPA: 'CPA',
64
+ efforts: 'Reasoning efforts', defaultEffort: 'Default effort', description: 'Description', empty: 'Empty', effortUnknown: 'Reasoning efforts not reported',
65
+ },
66
+ zh: {
67
+ tab: 'CLIProxyAPI',
68
+ title: 'CLIProxyAPI',
69
+ intro: '连接 CLIProxyAPI 服务并导入其模型目录。',
70
+ imageCapability: '会根据模型目录中的图片能力声明自动启用图片输入。',
71
+ loading: '正在读取 CLIProxyAPI 设置…',
72
+ unavailable: '当前 Web 配置中无法访问 CLIProxyAPI 设置。',
73
+ readOnly: '当前连接的设置为只读。',
74
+ connection: '连接',
75
+ actions: '操作',
76
+ baseURL: 'Base URL',
77
+ apiKey: 'API Key',
78
+ apiKeyPlaceholder: '无鉴权的 CLIProxyAPI 可留空',
79
+ apiKeyConfiguredPlaceholder: '已保存 API Key',
80
+ credentialConfiguredLabel: '已配置',
81
+ save: '保存并启用',
82
+ saving: '保存中…',
83
+ saved: '已保存,CLIProxyAPI 模型目录已同步。',
84
+ syncTimeout: '配置已保存,但图片与思考能力尚未同步。请检查供应商刷新日志后重试。',
85
+ saveFailed: '无法保存 CLIProxyAPI 配置。请重新加载设置后重试。',
86
+ baseRequired: '请填写 Base URL。',
87
+ baseInvalid: 'Base URL 必须是有效的 HTTP 或 HTTPS 地址。',
88
+ noModels: 'CLIProxyAPI 未返回可用模型。',
89
+ models: '可用模型',
90
+ loadModels: '加载模型',
91
+ loadingModels: '正在加载模型…',
92
+ modelsHelp: '禁用的模型会从 DSH 中移除,已有 DSH 会话的请求也可能失败。直接调用 CPA API 不受影响。新模型默认启用。',
93
+ loadFailed: '无法加载模型目录。请检查 URL 和 API Key 后重试。',
94
+ metadataHelp: '上下文、输出上限和输入类型为实际配置;服务未提供时可能使用默认值。',
95
+ noEnabledRefresh: '当前目录没有已启用的模型。启用模型并保存前,原供应商列表仍然生效。',
96
+ needOneModel: '请至少启用一个可用模型。DSH 不支持保存空的供应商模型目录。',
97
+ selectionUnavailable: '模型选择设置不可用或为只读。',
98
+ partialSave: '保存未完成。模型选择、API Key 或配置可能已经更改;请重新加载设置后重试。',
99
+ search: '按名称或 ID 搜索模型', selectedCount: '已选 / 总数', selectAll: '全选已加载模型', deselectAll: '取消选择所有已加载模型',
100
+ noMatches: '没有匹配的模型。', enabled: '启用', details: '模型详情', rawDetails: '所有 CPA 字段',
101
+ context: '上下文窗口', output: '最大输出', modalities: '输入类型', sourceDefault: '默认值', sourceCPA: 'CPA',
102
+ efforts: '推理等级', defaultEffort: '默认等级', description: '描述', empty: '空', effortUnknown: '未提供推理等级',
103
+ },
104
+ }
105
+ Object.assign(copy.en, { save: 'Save', saved: 'Changes saved.', statusNotConfigured: 'Not configured.', statusDisabled: 'Connection disabled.', statusMissingKey: 'Credential is missing.', statusLoading: 'Loading catalog…', statusReady: 'Ready.', statusStale: 'Catalog is stale.', statusReconfiguring: 'Reconfiguring…', statusNoModels: 'No selected usable models; the connection is not ready.', statusError: 'Connection error.', activationFailed: 'Saved, but activation failed. The connection is not ready.', catalogError: 'Catalog refresh failed; the last good catalog may be stale.', uncertain: 'The result is unknown. Your draft is preserved; check the operation before trying again.', conflict: 'Settings changed elsewhere. Your draft is preserved; review it before saving again.', operationId: 'Operation ID', reconcile: 'Check operation', authAction: 'Authentication action', authkeep: 'Keep current identity', authreplace: 'Replace with a key', authreference: 'Use a credential reference', authnone: 'No authentication', reference: 'Credential reference', keyRequired: 'Enter a key.', referenceRequired: 'Enter a credential reference.', reuseConsent: 'Allow the saved identity to be used with this endpoint', reuseRequired: 'Explicitly allow credential reuse before previewing this endpoint.', httpWarning: 'HTTP is not encrypted; credentials and requests can be exposed.', policy: 'Connection policy (JSON object)', policyInvalid: 'Enter a valid policy JSON object.', refresh: 'Refresh saved catalog', saveSelection: 'Save model selection only', disconnect: 'Disable connection', enableConnection: 'Enable connection', disconnectHelp: 'Disabling preserves the endpoint, key and model selection; existing sessions may fail until re-enabled.', forgetKey: 'Forget key', forgetHelp: 'Forgetting the key can make the connection unavailable; it does not select keyless authentication.', importLegacy: 'Import legacy connection', openSettings: 'Open Plugins, then select CLIProxyAPI', sourceOverride: 'override' })
106
+ Object.assign(copy.zh, { save: '保存', saved: '更改已保存。', statusNotConfigured: '尚未配置。', statusDisabled: '连接已停用。', statusMissingKey: '缺少凭据。', statusLoading: '正在加载目录…', statusReady: '已就绪。', statusStale: '目录已过期。', statusReconfiguring: '正在重新配置…', statusNoModels: '没有已选可用模型;连接尚未就绪。', statusError: '连接错误。', activationFailed: '已保存,但激活失败。连接尚未就绪。', catalogError: '目录刷新失败;上次成功的目录可能已过期。', uncertain: '结果未知。草稿已保留;重试前请检查操作。', conflict: '设置已在其他位置更改。草稿已保留;保存前请检查。', operationId: '操作 ID', reconcile: '检查操作', authAction: '身份验证操作', authkeep: '保留当前身份', authreplace: '替换为密钥', authreference: '使用凭据引用', authnone: '不使用身份验证', reference: '凭据引用', keyRequired: '请输入密钥。', referenceRequired: '请输入凭据引用。', reuseConsent: '允许在此地址使用已保存的身份', reuseRequired: '预览此地址前请明确允许重用凭据。', httpWarning: 'HTTP 未加密,凭据和请求可能泄露。', policy: '连接策略(JSON 对象)', policyInvalid: '请输入有效的策略 JSON 对象。', refresh: '刷新已保存的目录', saveSelection: '仅保存模型选择', disconnect: '停用连接', enableConnection: '启用连接', disconnectHelp: '停用会保留地址、密钥和模型选择;重新启用前现有会话可能失败。', forgetKey: '忘记密钥', forgetHelp: '忘记密钥可能导致连接不可用;不会自动选择无验证模式。', importLegacy: '导入旧连接', openSettings: '打开插件,然后选择 CLIProxyAPI', sourceOverride: '用户覆盖' })
107
+ copy.ru = { title: 'CLIProxyAPI', intro: 'Подключение сервера CLIProxyAPI и выбор моделей.', connection: 'Подключение', actions: 'Действия', loading: 'Загрузка настроек…', unavailable: 'Настройки недоступны.', readOnly: 'Настройки доступны только для чтения.', baseURL: 'Адрес сервера', apiKey: 'Ключ API', baseRequired: 'Укажите адрес.', baseInvalid: 'Укажите корректный HTTP или HTTPS адрес.', save: 'Сохранить', saved: 'Изменения сохранены.', loadModels: 'Предпросмотр моделей', loadFailed: 'Не удалось загрузить каталог.', modelsHelp: 'Отключённые модели недоступны в DSH; существующие сессии могут завершиться ошибкой. Прямые вызовы CPA не блокируются. Новые модели включены по умолчанию.', search: 'Поиск по имени или ID', selectedCount: 'выбрано из', selectAll: 'Выбрать все модели', deselectAll: 'Снять выбор со всех', noMatches: 'Модели не найдены.', enabled: 'Включить', details: 'Сведения о модели', rawDetails: 'Все поля CPA', context: 'Контекст', output: 'Максимальный вывод', modalities: 'Типы входа', sourceDefault: 'по умолчанию', sourceCPA: 'CPA', sourceOverride: 'переопределение', efforts: 'Уровни рассуждения', defaultEffort: 'Уровень по умолчанию', description: 'Описание', empty: 'Пусто', effortUnknown: 'Уровни рассуждения не указаны', statusNotConfigured: 'Не настроено.', statusDisabled: 'Подключение отключено.', statusMissingKey: 'Нет учётных данных.', statusLoading: 'Загрузка каталога…', statusReady: 'Готово.', statusStale: 'Каталог устарел.', statusReconfiguring: 'Применение настроек…', statusNoModels: 'Нет выбранных доступных моделей; подключение не готово.', statusError: 'Ошибка подключения.', activationFailed: 'Сохранено, но активация не удалась. Подключение не готово.', catalogError: 'Обновить каталог не удалось; предыдущая копия может устареть.', uncertain: 'Результат неизвестен. Черновик сохранён; проверьте операцию перед повтором.', conflict: 'Настройки изменены в другом месте. Черновик сохранён; проверьте его.', operationId: 'ID операции', reconcile: 'Проверить операцию', authAction: 'Авторизация', authkeep: 'Оставить текущие данные', authreplace: 'Заменить ключ', authreference: 'Использовать ссылку на данные', authnone: 'Без авторизации', reference: 'Ссылка на учётные данные', keyRequired: 'Укажите ключ.', referenceRequired: 'Укажите ссылку.', reuseConsent: 'Разрешить использовать сохранённые данные с этим адресом', reuseRequired: 'Для предпросмотра явно разрешите повторное использование данных.', httpWarning: 'HTTP не шифруется: ключ и запросы могут быть раскрыты.', policy: 'Политика подключения (объект JSON)', policyInvalid: 'Укажите корректный объект JSON.', refresh: 'Обновить сохранённый каталог', saveSelection: 'Сохранить только выбор моделей', disconnect: 'Отключить подключение', enableConnection: 'Включить подключение', disconnectHelp: 'Отключение сохраняет адрес, ключ и выбор моделей; существующие сессии могут перестать работать.', forgetKey: 'Забыть ключ', forgetHelp: 'Удаление ключа может сделать подключение недоступным и не включает режим без авторизации.', importLegacy: 'Импортировать прежнее подключение', openSettings: 'Открыть Plugins и выбрать CLIProxyAPI', tab: 'CLIProxyAPI', imageCapability: 'Ввод изображений включается автоматически для моделей, которые заявляют поддержку изображений.', apiKeyPlaceholder: 'Необязательно для сервера CLIProxyAPI без ключа', apiKeyConfiguredPlaceholder: 'Ключ API уже сохранён', credentialConfiguredLabel: 'Настроено', saving: 'Сохранение…', syncTimeout: 'Профиль сохранён, но возможности изображений и рассуждений не синхронизированы. Проверьте журналы обновления провайдера и повторите попытку.', noModels: 'CLIProxyAPI не вернул доступных моделей.', models: 'Доступные модели', loadingModels: 'Загрузка моделей…', metadataHelp: 'Контекст, лимиты вывода и типы входа — действующие настройки; если сервер их не сообщает, могут использоваться значения по умолчанию.', noEnabledRefresh: 'В текущем каталоге нет включённых моделей. Прежний список провайдера остаётся активным, пока вы не включите модель и не сохраните.', needOneModel: 'Включите хотя бы одну доступную модель. DSH не может сохранить пустой каталог провайдера.', selectionUnavailable: 'Настройки выбора моделей недоступны или только для чтения.', partialSave: 'Сохранение не завершено. Выбор моделей, ключ или профиль могли измениться; перезагрузите настройки и повторите.' }
108
+ Object.assign(copy.en, { openSettings: 'Open Plugins', chooseCPA: 'Then select CLIProxyAPI.', saveFailed: 'The operation was refused. Your draft is preserved.', revision: 'Readback revision' })
109
+ Object.assign(copy.ru, { openSettings: 'Открыть плагины', chooseCPA: 'Затем выберите CLIProxyAPI.', saveFailed: 'Операция отклонена. Черновик сохранён.', revision: 'Прочитанная версия' })
110
+ Object.assign(copy.zh, { openSettings: '打开插件', chooseCPA: '然后选择 CLIProxyAPI。', saveFailed: '操作被拒绝,草稿已保留。', revision: '读取的版本' })
111
+ Object.assign(copy.en, { previewNone: 'Save keyless authentication before previewing without the saved credential.' })
112
+ Object.assign(copy.ru, { previewNone: 'Чтобы выполнить предпросмотр без сохранённого ключа, сначала сохраните режим без авторизации.' })
113
+ Object.assign(copy.zh, { previewNone: '请先保存无验证模式,再预览不使用已保存凭据的目录。' })
114
+ Object.assign(copy.en, { retryOperation: 'Retry the same operation', returnToEdit: 'Return to editing', conflictPending: 'The result cannot be confirmed because settings changed. Return to editing and review your draft before saving.', diagnosticHttp: 'HTTP response', diagnosticPayload: 'Request payload', diagnosticBeforeHttp: 'Before HTTP response', diagnosticTimeout: 'Timeout', diagnosticAbort: 'Cancelled', diagnosticSseCompleted: 'Stream completed', diagnosticSseFailed: 'Stream failed', diagnosticSseIncomplete: 'Stream incomplete', diagnosticStatus: 'HTTP status', diagnosticRequestId: 'Request ID', diagnosticAt: 'Time', diagnosticToolCount: 'Tool count', catalogAuth: 'Catalog authentication failed. Check the credential.', catalogTimeout: 'Catalog request timed out.', catalogTransport: 'Could not reach the catalog endpoint.', catalogInvalidResponse: 'The catalog response is invalid.', catalogTooLarge: 'The catalog response is too large.' })
115
+ Object.assign(copy.ru, { retryOperation: 'Повторить ту же операцию', returnToEdit: 'Вернуться к редактированию', conflictPending: 'Подтвердить результат нельзя: настройки изменились. Вернитесь к редактированию и проверьте черновик перед сохранением.', diagnosticHttp: 'Ответ HTTP', diagnosticPayload: 'Данные запроса', diagnosticBeforeHttp: 'До ответа HTTP', diagnosticTimeout: 'Время ожидания истекло', diagnosticAbort: 'Отменено', diagnosticSseCompleted: 'Поток завершён', diagnosticSseFailed: 'Ошибка потока', diagnosticSseIncomplete: 'Поток не завершён', diagnosticStatus: 'Статус HTTP', diagnosticRequestId: 'ID запроса', diagnosticAt: 'Время', diagnosticToolCount: 'Число инструментов', catalogAuth: 'Ошибка авторизации каталога. Проверьте учётные данные.', catalogTimeout: 'Время ожидания каталога истекло.', catalogTransport: 'Не удалось подключиться к каталогу.', catalogInvalidResponse: 'Некорректный ответ каталога.', catalogTooLarge: 'Ответ каталога слишком велик.' })
116
+ Object.assign(copy.zh, { retryOperation: '重试同一操作', returnToEdit: '返回编辑', conflictPending: '设置已更改,无法确认结果。请返回编辑并在保存前检查草稿。', diagnosticHttp: 'HTTP 响应', diagnosticPayload: '请求载荷', diagnosticBeforeHttp: 'HTTP 响应前', diagnosticTimeout: '超时', diagnosticAbort: '已取消', diagnosticSseCompleted: '流已完成', diagnosticSseFailed: '流失败', diagnosticSseIncomplete: '流未完成', diagnosticStatus: 'HTTP 状态', diagnosticRequestId: '请求 ID', diagnosticAt: '时间', diagnosticToolCount: '工具数量', catalogAuth: '目录身份验证失败,请检查凭据。', catalogTimeout: '目录请求超时。', catalogTransport: '无法连接目录地址。', catalogInvalidResponse: '目录响应无效。', catalogTooLarge: '目录响应过大。' })
117
+
118
+ Object.assign(copy.en, { protocol: 'Wire protocol', protocolHelp: 'How DSH talks to this gateway. It must match what the server exposes; the model catalog is read the same way for every protocol.', protocolResponses: 'OpenAI Responses', protocolCompletions: 'OpenAI Chat Completions', protocolAnthropic: 'Anthropic Messages', protocolGoogle: 'Google Generative AI' })
119
+ Object.assign(copy.ru, { protocol: 'Протокол обмена', protocolHelp: 'Как DSH обращается к шлюзу. Должен совпадать с тем, что предоставляет сервер; каталог моделей запрашивается одинаково для всех протоколов.', protocolResponses: 'OpenAI Responses', protocolCompletions: 'OpenAI Chat Completions', protocolAnthropic: 'Anthropic Messages', protocolGoogle: 'Google Generative AI' })
120
+ Object.assign(copy.en, { restoreSaved: 'Restore saved connection', restoreHelp: 'A copy of the last saved connection is kept by the plugin, outside the profile. Use it when the profile was rebuilt or the settings entry disappeared.', restoreMissing: 'No saved connection to restore.', recoveryWarn: 'Recovery', durableSaved: 'Saved copy' })
121
+ Object.assign(copy.ru, { restoreSaved: 'Восстановить сохранённое подключение', restoreHelp: 'Плагин хранит копию последнего сохранённого подключения вне профиля. Пригодится, если профиль пересобрали или запись настроек пропала.', restoreMissing: 'Нет сохранённого подключения для восстановления.', recoveryWarn: 'Восстановление', durableSaved: 'Сохранённая копия' })
122
+ Object.assign(copy.zh, { restoreSaved: '恢复已保存的连接', restoreHelp: '插件在配置文件之外保留上次保存连接的副本。当配置文件重建或设置条目消失时可用。', restoreMissing: '没有可恢复的已保存连接。', recoveryWarn: '恢复', durableSaved: '已保存副本' })
123
+ Object.assign(copy.zh, { protocol: '通信协议', protocolHelp: 'DSH 与该网关通信的方式,必须与服务端提供的协议一致;模型目录对所有协议使用同一地址。', protocolResponses: 'OpenAI Responses', protocolCompletions: 'OpenAI Chat Completions', protocolAnthropic: 'Anthropic Messages', protocolGoogle: 'Google Generative AI' })
124
+
125
+ // The page body sits inside the Host's plugin page, which already draws the
126
+ // title, the one-liner and the 960px column. This sheet only styles what the
127
+ // Host does not: the sections, the fields and the model list. Every colour is
128
+ // a Host theme token, so light and dark follow the app without a second copy.
129
+ const SETTINGS_CSS = `
130
+ .cpa-page { display:flex; flex-direction:column; gap:28px; min-width:0; color:var(--dsw-alias-label-primary); font:inherit; }
131
+ .cpa-section { display:flex; flex-direction:column; gap:12px; min-width:0; }
132
+ .cpa-section-title { margin:0; font-size:14px; line-height:20px; font-weight:500; }
133
+ .cpa-status { display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-width:0; }
134
+ .cpa-status-text { margin:0; font-size:13px; line-height:20px; color:var(--dsw-alias-label-secondary); overflow-wrap:anywhere; }
135
+ .cpa-status-text[data-tone='error'] { color:var(--dsw-alias-state-error-primary); }
136
+ .cpa-note { margin:0; font-size:12px; line-height:18px; color:var(--dsw-alias-label-tertiary); overflow-wrap:anywhere; }
137
+ .cpa-note[data-tone='warn'] { color:var(--dsw-alias-state-warn-primary); }
138
+ .cpa-note[data-tone='error'] { color:var(--dsw-alias-state-error-primary); }
139
+ .cpa-form { display:flex; flex-direction:column; gap:28px; min-width:0; }
140
+ .cpa-field { display:flex; flex-direction:column; gap:6px; min-width:0; }
141
+ .cpa-label { font-size:13px; font-weight:500; line-height:1.5; }
142
+ .cpa-input, .cpa-select, .cpa-textarea { box-sizing:border-box; width:100%; border:.5px solid var(--dsw-alias-border-l4); border-radius:var(--dsw-radius-md); background:var(--dsw-alias-bg-layer-3); color:var(--dsw-alias-label-primary); font:inherit; font-size:13px; line-height:1.5; }
143
+ .cpa-input, .cpa-select { height:34px; padding:0 12px; }
144
+ .cpa-select { appearance:none; padding-right:32px; }
145
+ .cpa-select-wrap { position:relative; display:block; min-width:0; }
146
+ .cpa-select-chevron { position:absolute; top:50%; right:13px; width:6px; height:6px; margin-top:-4px; border-right:1.5px solid var(--dsw-alias-label-tertiary); border-bottom:1.5px solid var(--dsw-alias-label-tertiary); transform:rotate(45deg); pointer-events:none; }
147
+ .cpa-textarea { min-height:88px; padding:8px 12px; resize:vertical; font-family:var(--dsh-font-family-code-default); font-size:12px; line-height:1.6; }
148
+ .cpa-input:focus-visible, .cpa-select:focus-visible, .cpa-textarea:focus-visible { outline:none; border-color:var(--dsw-alias-state-business-primary); }
149
+ .cpa-input:disabled, .cpa-select:disabled, .cpa-textarea:disabled { color:var(--dsw-alias-label-tertiary); cursor:default; }
150
+ .cpa-toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-width:0; }
151
+ .cpa-count { font-size:12px; line-height:18px; color:var(--dsw-alias-label-tertiary); }
152
+ .cpa-models { display:flex; flex-direction:column; min-width:0; max-height:min(520px,60vh); overflow-y:auto; scrollbar-gutter:stable; border:.5px solid var(--dsw-alias-border-l2); border-radius:var(--dsw-radius-lg); }
153
+ .cpa-model { display:flex; flex-direction:column; gap:6px; min-width:0; padding:12px 14px; }
154
+ .cpa-model + .cpa-model { border-top:.5px solid var(--dsw-alias-border-l2); }
155
+ .cpa-model-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; min-width:0; }
156
+ .cpa-model-id { min-width:0; font-family:var(--dsh-font-family-code-default); font-size:12px; line-height:18px; color:var(--dsw-alias-label-tertiary); overflow-wrap:anywhere; }
157
+ .cpa-trace { margin-top:2px; }
158
+ .cpa-model-meta { display:flex; flex-wrap:wrap; align-items:center; gap:6px; min-width:0; }
159
+ .cpa-details { min-width:0; font-size:12px; line-height:1.6; }
160
+ .cpa-details > summary { cursor:pointer; color:var(--dsw-alias-label-tertiary); }
161
+ .cpa-details > summary:hover { color:var(--dsw-alias-label-secondary); }
162
+ .cpa-details-body { display:flex; flex-direction:column; gap:4px; padding:8px 0 0 14px; min-width:0; }
163
+ .cpa-details-body p { margin:0; overflow-wrap:anywhere; }
164
+ .cpa-json { margin:0; padding:0 0 0 16px; min-width:0; overflow-wrap:anywhere; }
165
+ .cpa-json li { margin:2px 0; }
166
+ .cpa-value { white-space:pre-wrap; overflow-wrap:anywhere; }
167
+ .cpa-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-width:0; }
168
+ .cpa-actions-end { margin-left:auto; }
169
+ .cpa-pending { display:flex; flex-direction:column; gap:8px; padding:12px 14px; border:.5px solid var(--dsw-alias-border-l4); border-radius:var(--dsw-radius-lg); background:var(--dsw-alias-bg-layer-1); }
170
+ `
171
+
172
+ // One dot per connection state, so the page reads at a glance instead of
173
+ // asking the reader to parse a sentence first.
174
+ const statusDots = { 'not-configured': 'idle', disabled: 'idle', 'missing-credential': 'warning', 'catalog-loading': 'ongoing', ready: 'done', stale: 'warning', reconfiguring: 'ongoing', 'no-models': 'warning', error: 'error' }
175
+
176
+ function validBaseURL(value, messages) {
177
+ if (!value) throw new Error(messages.baseRequired)
178
+ let parsed
179
+ try {
180
+ parsed = new URL(value)
181
+ } catch {
182
+ throw new Error(messages.baseInvalid)
183
+ }
184
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error(messages.baseInvalid)
185
+ }
186
+
187
+ function createOperationId() {
188
+ return globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(36).slice(2)
189
+ }
190
+
191
+ async function request(path, body) {
192
+ const transportFetch = window.__DSH_TRANSPORT__?.fetch || globalThis.fetch
193
+ const response = await transportFetch(path, body === undefined
194
+ ? { credentials: 'same-origin', cache: 'no-store' }
195
+ : { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', cache: 'no-store', body: JSON.stringify(body) })
196
+ const result = await response.json()
197
+ if (!result?.ok) {
198
+ const error = new Error('gateway')
199
+ error.code = result?.error?.code
200
+ throw error
201
+ }
202
+ return result.value
203
+ }
204
+
205
+ const readState = () => request('/api/cliproxyapi/state')
206
+ const command = (body) => request('/api/cliproxyapi/command', body)
207
+ function metadataValue(value, t) {
208
+ if (value !== null && typeof value === 'object') {
209
+ const entries = Object.entries(value)
210
+ if (!entries.length) return React.createElement('span', { className: 'cpa-note' }, t('empty'))
211
+ return React.createElement('ul', { className: 'cpa-json' }, entries.map(([key, child]) =>
212
+ React.createElement('li', { key },
213
+ child !== null && typeof child === 'object'
214
+ ? React.createElement('details', { className: 'cpa-details' }, React.createElement('summary', null, key), metadataValue(child, t))
215
+ : React.createElement('span', { className: 'cpa-value' }, key + ': ', String(child)),
216
+ ),
217
+ ))
218
+ }
219
+ return React.createElement('span', { className: 'cpa-value' }, String(value))
220
+ }
221
+
222
+ function modelEfforts(model, t) {
223
+ const raw = model.catalogMetadata || {}
224
+ const advertised = Array.isArray(raw.supported_reasoning_levels)
225
+ ? raw.supported_reasoning_levels.map((entry) => typeof entry === 'string' ? entry : entry?.effort).filter((value) => typeof value === 'string')
226
+ : Object.values(model.reasoningEfforts || {})
227
+ const efforts = [...new Set(advertised)]
228
+ return React.createElement('div', { className: 'cpa-model-meta' },
229
+ React.createElement('span', { className: 'cpa-count' }, efforts.length ? t('efforts') + ':' : t('effortUnknown')),
230
+ efforts.map((effort) => React.createElement(Tag, { key: effort, tone: effort === raw.default_reasoning_level ? 'info' : 'neutral' }, effort)),
231
+ typeof (model.defaultReasoningEffort || raw.default_reasoning_level) === 'string' ? React.createElement('span', { className: 'cpa-count' }, t('defaultEffort') + ': ' + (model.defaultReasoningEffort || raw.default_reasoning_level)) : null,
232
+ )
233
+ }
234
+
235
+ function modelDetails(model, t) {
236
+ const raw = model.catalogMetadata && typeof model.catalogMetadata === 'object' ? model.catalogMetadata : {}
237
+ const source = (key) => model.catalogSources?.[key] === 'default' ? t('sourceDefault') : model.catalogSources?.[key] === 'cpa' ? t('sourceCPA') : model.catalogSources?.[key] === 'override' ? t('sourceOverride') : ''
238
+ const line = (label, value, provenance) => value === undefined || value === null ? null : React.createElement('p', null, label, ': ', String(value), provenance ? ' (' + provenance + ')' : '')
239
+ return React.createElement('details', { className: 'cpa-details' },
240
+ React.createElement('summary', null, t('details')),
241
+ React.createElement('div', { className: 'cpa-details-body' },
242
+ line(t('context'), model.contextWindow?.toLocaleString(), source('contextWindow')),
243
+ line(t('output'), model.maxTokens?.toLocaleString(), source('maxTokens')),
244
+ line(t('modalities'), (model.inputModalities || model.input || []).join(', '), source('inputModalities')),
245
+ raw.description != null ? React.createElement('p', null, t('description'), ': ', metadataValue(raw.description, t)) : null,
246
+ raw.default_reasoning_level != null ? React.createElement('p', null, t('defaultEffort'), ': ', metadataValue(raw.default_reasoning_level, t)) : null,
247
+ Array.isArray(raw.supported_reasoning_levels) ? React.createElement('div', null,
248
+ React.createElement('p', null, t('efforts')),
249
+ React.createElement('ul', { className: 'cpa-json' }, raw.supported_reasoning_levels.map((entry, index) =>
250
+ React.createElement('li', { key: index, className: 'cpa-value' }, typeof entry === 'string' ? entry
251
+ : entry && typeof entry.effort === 'string' ? entry.effort + (typeof entry.description === 'string' ? ' — ' + entry.description : '')
252
+ : metadataValue(entry, t)),
253
+ )),
254
+ ) : model.reasoningEfforts ? React.createElement('p', null, t('efforts'), ': ', metadataValue(model.reasoningEfforts, t)) : null,
255
+ React.createElement('details', { className: 'cpa-details' }, React.createElement('summary', null, t('rawDetails')), metadataValue(raw, t)),
256
+ ),
257
+ )
258
+ }
259
+
260
+
261
+ const statusKeys = { 'not-configured': 'statusNotConfigured', disabled: 'statusDisabled', 'missing-credential': 'statusMissingKey', 'catalog-loading': 'statusLoading', ready: 'statusReady', stale: 'statusStale', reconfiguring: 'statusReconfiguring', 'no-models': 'statusNoModels', error: 'statusError' }
262
+ const catalogErrorKeys = { CATALOG_AUTH: 'catalogAuth', CATALOG_TIMEOUT: 'catalogTimeout', CATALOG_TRANSPORT: 'catalogTransport', CATALOG_INVALID_RESPONSE: 'catalogInvalidResponse', CATALOG_TOO_LARGE: 'catalogTooLarge' }
263
+ const diagnosticStageKeys = { http: 'diagnosticHttp', payload: 'diagnosticPayload', 'before-http': 'diagnosticBeforeHttp', timeout: 'diagnosticTimeout', abort: 'diagnosticAbort', 'sse-completed': 'diagnosticSseCompleted', 'sse-failed': 'diagnosticSseFailed', 'sse-incomplete': 'diagnosticSseIncomplete' }
264
+
265
+ function diagnosticView(diagnostic, index, t) {
266
+ const stage = Object.hasOwn(diagnosticStageKeys, diagnostic?.stage) ? diagnosticStageKeys[diagnostic.stage] : null
267
+ if (!stage) return null
268
+ const fields = [t(stage)]
269
+ if (Number.isInteger(diagnostic.status) && diagnostic.status >= 100 && diagnostic.status <= 599) fields.push(t('diagnosticStatus') + ': ' + diagnostic.status)
270
+ if (typeof diagnostic.requestId === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(diagnostic.requestId)) fields.push(t('diagnosticRequestId') + ': ' + diagnostic.requestId)
271
+ if (Number.isFinite(diagnostic.at) && diagnostic.at >= 0 && diagnostic.at <= 8640000000000000) fields.push(t('diagnosticAt') + ': ' + new Date(diagnostic.at).toISOString())
272
+ if (Number.isSafeInteger(diagnostic.toolCount) && diagnostic.toolCount >= 0) fields.push(t('diagnosticToolCount') + ': ' + diagnostic.toolCount)
273
+ return React.createElement('p', { key: index, className: 'cpa-note', role: 'status' }, fields.join(' · '))
274
+ }
275
+
276
+ function SettingsTab({ t, view, useScope }) {
277
+ if (view === 'summary') return t('intro')
278
+ const snapshot = useScope((value) => value)
279
+ const [state, setState] = useState(null)
280
+ const [baseURL, setBaseURL] = useState('')
281
+ const [apiKey, setApiKey] = useState('')
282
+ const [authType, setAuthType] = useState('keep')
283
+ const [reference, setReference] = useState('')
284
+ const [reuse, setReuse] = useState(false)
285
+ const [policy, setPolicy] = useState('{}')
286
+ const [protocol, setProtocol] = useState(PROTOCOLS[0])
287
+ const [draft, setDraft] = useState({})
288
+ const [models, setModels] = useState(null)
289
+ const [search, setSearch] = useState('')
290
+ const [busy, setBusy] = useState(false)
291
+ const [feedback, setFeedback] = useState('')
292
+ const [pending, setPending] = useState(null)
293
+ const [lastOperation, setLastOperation] = useState(null)
294
+ const requestGeneration = React.useRef(0)
295
+ const writable = snapshot?.status === 'ready' && snapshot.writable && state && !busy && !pending
296
+ useEffect(() => {
297
+ let active = true
298
+ readState().then((value) => {
299
+ if (!active) return
300
+ setState(value); setBaseURL(value.baseURL || ''); setPolicy(JSON.stringify(value.policy || {}, null, 2)); setModels(value.models || [])
301
+ setProtocol(PROTOCOLS.includes(value.policy?.api) ? value.policy.api : PROTOCOLS[0])
302
+ }).catch(() => { if (active) setFeedback(t('unavailable')) })
303
+ return () => { active = false; requestGeneration.current += 1 }
304
+ }, [])
305
+ const complete = (operation, value) => {
306
+ setState(value); setModels(value.models || []); setPending(null); setFeedback(t('saved'))
307
+ if (operation.type === 'save-connection') { setDraft({}); setApiKey(''); setAuthType('keep') }
308
+ if (operation.type === 'save-selection') setDraft({})
309
+ }
310
+ const reconcile = async (operation) => {
311
+ try {
312
+ const value = await readState()
313
+ setState(value)
314
+ if (value.operationId === operation.operationId && value.revision > operation.expectedRevision) {
315
+ complete(operation, value)
316
+ return true
317
+ }
318
+ setFeedback(t(value.revision !== operation.expectedRevision ? 'conflictPending' : 'uncertain'))
319
+ return false
320
+ } catch { setFeedback(t('uncertain')); return false }
321
+ }
322
+ const sendOperation = async (operation, retry = false) => {
323
+ setBusy(true); setFeedback('')
324
+ try {
325
+ const value = await command(operation)
326
+ complete(operation, value)
327
+ } catch (error) {
328
+ if (String(error.code || '').toUpperCase().includes('CONFLICT')) {
329
+ if (retry) { setPending(operation); await reconcile(operation) }
330
+ else { try { setState(await readState()) } catch { }; setFeedback(t('conflict')) }
331
+ } else if (error.code && !String(error.code).toUpperCase().includes('UNKNOWN')) {
332
+ if (retry) setPending(operation)
333
+ setFeedback(t('saveFailed'))
334
+ } else {
335
+ setPending(operation)
336
+ await reconcile(operation)
337
+ }
338
+ } finally { setBusy(false) }
339
+ }
340
+ const mutate = async (type, fields = {}) => {
341
+ if (!writable) return
342
+ const operation = { type, expectedRevision: state.revision, operationId: createOperationId(), ...fields }
343
+ setLastOperation(operation.operationId)
344
+ await sendOperation(operation)
345
+ }
346
+ const returnToEdit = async (operation) => {
347
+ setBusy(true)
348
+ try {
349
+ const value = await readState()
350
+ if (value.operationId === operation.operationId && value.revision > operation.expectedRevision) complete(operation, value)
351
+ else { setState(value); setPending(null); setFeedback(t('conflict')) }
352
+ } catch { setFeedback(t('uncertain')) }
353
+ finally { setBusy(false) }
354
+ }
355
+ const submit = (event) => {
356
+ event.preventDefault()
357
+ try {
358
+ validBaseURL(baseURL.trim(), { baseRequired: t('baseRequired'), baseInvalid: t('baseInvalid') })
359
+ const parsedPolicy = JSON.parse(policy)
360
+ if (!parsedPolicy || Array.isArray(parsedPolicy) || typeof parsedPolicy !== 'object') throw new Error(t('policyInvalid'))
361
+ parsedPolicy.api = protocol
362
+ if (authType === 'replace' && !apiKey) throw new Error(t('keyRequired'))
363
+ if (authType === 'reference' && !reference.trim()) throw new Error(t('referenceRequired'))
364
+ const authEdit = authType === 'replace' ? { type: 'replace', key: apiKey } : authType === 'reference' ? { type: 'reference', ref: reference.trim(), allowEndpointReuse: reuse } : authType === 'keep' ? { type: 'keep', allowEndpointReuse: reuse } : { type: 'none' }
365
+ return mutate('save-connection', { baseURL: baseURL.trim(), authEdit, policy: parsedPolicy, selectionChanges: draft })
366
+ } catch (error) { setFeedback([t('baseRequired'), t('baseInvalid'), t('keyRequired'), t('referenceRequired')].includes(error.message) ? error.message : t('policyInvalid')) }
367
+ }
368
+ const preview = async () => {
369
+ const generation = ++requestGeneration.current
370
+ setBusy(true); setFeedback('')
371
+ try {
372
+ validBaseURL(baseURL.trim(), { baseRequired: t('baseRequired'), baseInvalid: t('baseInvalid') })
373
+ if (state?.baseURL !== baseURL.trim() && state?.keyConfigured && !(authType === 'replace' && apiKey) && !reuse) throw new Error(t('reuseRequired'))
374
+ if (authType === 'none' && state?.keyConfigured) throw new Error(t('previewNone'))
375
+ const value = await command({ type: 'preview-models', baseURL: baseURL.trim(), ...(authType === 'replace' && apiKey ? { apiKey } : {}), allowEndpointReuse: reuse })
376
+ if (generation === requestGeneration.current) setModels(value)
377
+ } catch (error) { if (generation === requestGeneration.current) setFeedback([t('reuseRequired'), t('previewNone')].includes(error.message) ? error.message : t('loadFailed')) }
378
+ finally { if (generation === requestGeneration.current) setBusy(false) }
379
+ }
380
+ const refresh = async () => {
381
+ setBusy(true); setFeedback('')
382
+ try { const value = await command({ type: 'refresh-models', connectionGeneration: state.generation }); setState(value); setModels(value.models || []) }
383
+ catch { setFeedback(t('loadFailed')); try { setState(await readState()) } catch { } }
384
+ finally { setBusy(false) }
385
+ }
386
+ const list = models || []
387
+ const selected = (model) => Object.hasOwn(draft, model.id) ? draft[model.id] : state?.modelSelection?.[model.id] !== false
388
+ const filtered = list.filter((model) => (model.name || '').toLowerCase().includes(search.toLowerCase()) || model.id.toLowerCase().includes(search.toLowerCase()))
389
+ const button = (label, action, disabled = !writable, variant = 'ghost', size) => React.createElement(Button, { type: 'button', variant, size, disabled, onClick: action }, t(label))
390
+ const note = (text, tone) => React.createElement('p', { className: 'cpa-note', 'data-tone': tone, role: 'status' }, text)
391
+ const field = (label, control, hint) => React.createElement('label', { className: 'cpa-field' },
392
+ React.createElement('span', { className: 'cpa-label' }, label),
393
+ control,
394
+ hint ? React.createElement('span', { className: 'cpa-note' }, hint) : null,
395
+ )
396
+ const section = (title, ...children) => React.createElement('section', { className: 'cpa-section' },
397
+ React.createElement('h3', { className: 'cpa-section-title' }, title),
398
+ ...children,
399
+ )
400
+ return React.createElement('div', { className: 'cpa-page', 'aria-busy': busy || !state },
401
+ React.createElement('style', { key: 'cpa-css' }, SETTINGS_CSS),
402
+ React.createElement('section', { className: 'cpa-section' },
403
+ React.createElement('div', { className: 'cpa-status' },
404
+ React.createElement(StateDot, { state: state ? statusDots[state.status] || 'error' : 'ongoing' }),
405
+ React.createElement('p', { className: 'cpa-status-text', role: 'status' }, state ? t(statusKeys[state.status] || 'statusError') : t('loading')),
406
+ ),
407
+ snapshot?.status === 'ready' && !snapshot.writable ? note(t('readOnly')) : null,
408
+ state?.recoveryError ? note(t('recoveryWarn') + ': ' + state.recoveryError.message, 'warn') : null,
409
+ state?.activationError ? React.createElement('p', { className: 'cpa-note', 'data-tone': 'error', role: 'alert' }, t('activationFailed'), ' ', state.activationError.code, state.activationError.message ? ' — ' + state.activationError.message : '') : null,
410
+ state?.catalog?.error ? note(t(Object.hasOwn(catalogErrorKeys, state.catalog.error.code) ? catalogErrorKeys[state.catalog.error.code] : 'catalogError'), 'warn') : null,
411
+ (state?.diagnostics || []).map((diagnostic, index) => diagnosticView(diagnostic, index, t)),
412
+ feedback ? note(feedback) : null,
413
+ state ? React.createElement('details', { className: 'cpa-details cpa-trace' },
414
+ React.createElement('summary', null, t('revision'), ': ', state.revision),
415
+ React.createElement('div', { className: 'cpa-details-body' },
416
+ state.operationId ? React.createElement('p', null, t('operationId'), ': ', state.operationId) : null,
417
+ lastOperation ? React.createElement('p', null, t('operationId'), ': ', lastOperation) : null,
418
+ ),
419
+ ) : null,
420
+ pending ? React.createElement('div', { className: 'cpa-pending' },
421
+ React.createElement('p', { className: 'cpa-note' }, t('operationId'), ': ', pending.operationId),
422
+ React.createElement('div', { className: 'cpa-actions' },
423
+ button('reconcile', async () => { setBusy(true); try { await reconcile(pending) } finally { setBusy(false) } }, busy, 'outline', 'sm'),
424
+ button('retryOperation', () => sendOperation(pending, true), busy || snapshot?.status !== 'ready' || !snapshot.writable, 'ghost', 'sm'),
425
+ button('returnToEdit', () => returnToEdit(pending), busy, 'ghost', 'sm'),
426
+ ),
427
+ ) : null,
428
+ ),
429
+ React.createElement('form', { className: 'cpa-form', onSubmit: submit },
430
+ section(t('connection'),
431
+ field(t('protocol'), React.createElement('span', { className: 'cpa-select-wrap' },
432
+ React.createElement('select', { className: 'cpa-select', name: 'protocol', value: protocol, disabled: !writable, onChange: (event) => setProtocol(event.currentTarget.value) }, PROTOCOLS.map((value) => React.createElement('option', { key: value, value }, t(protocolKeys[value])))),
433
+ React.createElement('span', { className: 'cpa-select-chevron', 'aria-hidden': 'true' }),
434
+ ), t('protocolHelp')),
435
+ field(t('baseURL'), React.createElement(Input, { type: 'url', value: baseURL, disabled: !writable, onChange: (event) => { requestGeneration.current += 1; setBaseURL(event.currentTarget.value); setReuse(false); setApiKey('') } })),
436
+ baseURL.trim().toLowerCase().startsWith('http:') ? React.createElement('p', { className: 'cpa-note', 'data-tone': 'warn', role: 'note' }, t('httpWarning')) : null,
437
+ field(t('authAction'), React.createElement('span', { className: 'cpa-select-wrap' },
438
+ React.createElement('select', { className: 'cpa-select', name: 'authAction', value: authType, disabled: !writable, onChange: (event) => setAuthType(event.currentTarget.value) }, ['keep', 'replace', 'reference', 'none'].map((type) => React.createElement('option', { key: type, value: type }, t('auth' + type)))),
439
+ React.createElement('span', { className: 'cpa-select-chevron', 'aria-hidden': 'true' }),
440
+ )),
441
+ authType === 'replace' ? field(t('apiKey'), React.createElement(Input, { type: 'password', value: apiKey, autoComplete: 'off', disabled: !writable || !state?.keyWritable, onChange: (event) => setApiKey(event.currentTarget.value) })) : null,
442
+ authType === 'reference' ? field(t('reference'), React.createElement(Input, { value: reference, disabled: !writable, onChange: (event) => setReference(event.currentTarget.value) })) : null,
443
+ state?.baseURL && baseURL.trim() !== state.baseURL && (authType === 'keep' || authType === 'reference') ? React.createElement(Checkbox, { label: t('reuseConsent'), checked: reuse, disabled: !writable, onChange: setReuse }) : null,
444
+ field(t('policy'), React.createElement('textarea', { className: 'cpa-textarea', value: policy, disabled: !writable, onChange: (event) => setPolicy(event.currentTarget.value) })),
445
+ ),
446
+ section(t('models'),
447
+ React.createElement('p', { className: 'cpa-note', role: 'note' }, t('modelsHelp')),
448
+ React.createElement('div', { className: 'cpa-toolbar' }, button('loadModels', preview, !writable, 'outline'), button('refresh', refresh, !writable || !state?.generation)),
449
+ field(t('search'), React.createElement(Input, { type: 'search', value: search, onChange: (event) => setSearch(event.currentTarget.value) })),
450
+ React.createElement('div', { className: 'cpa-toolbar' },
451
+ React.createElement('span', { className: 'cpa-count' }, list.filter(selected).length, ' / ', list.length, ' ', t('selectedCount')),
452
+ button('selectAll', () => setDraft((current) => ({ ...current, ...Object.fromEntries(list.map((model) => [model.id, true])) })), !writable, 'ghost', 'sm'),
453
+ button('deselectAll', () => setDraft((current) => ({ ...current, ...Object.fromEntries(list.map((model) => [model.id, false])) })), !writable, 'ghost', 'sm'),
454
+ ),
455
+ list.length && !list.filter(selected).length ? note(t('statusNoModels'), 'warn') : null,
456
+ list.length
457
+ ? React.createElement('div', { className: 'cpa-models' }, filtered.map((model) => React.createElement('div', { key: model.id, className: 'cpa-model' },
458
+ React.createElement('div', { className: 'cpa-model-head' },
459
+ React.createElement(Checkbox, { label: t('enabled') + ' ' + (model.name || model.id), checked: selected(model), disabled: !writable, onChange: (checked) => setDraft((current) => ({ ...current, [model.id]: checked })) }),
460
+ model.name && model.name !== model.id ? React.createElement('span', { className: 'cpa-model-id' }, model.id) : null,
461
+ ),
462
+ modelEfforts(model, t),
463
+ modelDetails(model, t),
464
+ )))
465
+ : React.createElement('p', { className: 'cpa-note' }, t('noModels')),
466
+ ),
467
+ section(t('actions'),
468
+ React.createElement('div', { className: 'cpa-actions' },
469
+ React.createElement(Button, { type: 'submit', variant: 'primary', disabled: !writable }, t('save')),
470
+ button('saveSelection', () => mutate('save-selection', { selectionChanges: draft }), !writable, 'outline'),
471
+ button(state?.enabled ? 'disconnect' : 'enableConnection', () => mutate('set-enabled', { enabled: !state.enabled }), !writable || !state?.configured),
472
+ !state?.baseURL && state?.durable?.available ? button('restoreSaved', () => mutate('restore-connection'), !writable, 'outline') : null,
473
+ ),
474
+ React.createElement('p', { className: 'cpa-note', role: 'note' }, t('disconnectHelp')),
475
+ React.createElement('p', { className: 'cpa-note', role: 'note' }, state?.durable?.available ? t('restoreHelp') : t('restoreMissing')),
476
+ React.createElement('div', { className: 'cpa-actions' },
477
+ button('forgetKey', () => mutate('forget-key'), !writable || !state?.keyConfigured),
478
+ state?.legacy?.available ? button('importLegacy', () => mutate('import-legacy', { expectedLegacyRevision: state.legacy.revision, fingerprint: state.legacy.fingerprint })) : null,
479
+ ),
480
+ React.createElement('p', { className: 'cpa-note', role: 'note' }, t('forgetHelp')),
481
+ ),
482
+ ),
483
+ )
484
+ }
485
+
486
+ function ModelsFooter({ t, pluginNavigation }) {
487
+ const [state, setState] = useState(null)
488
+ useEffect(() => { let active = true; readState().then((value) => { if (active) setState(value) }).catch(() => { }); return () => { active = false } }, [])
489
+ return React.createElement('div', { className: 'cpa-page' },
490
+ React.createElement('style', { key: 'cpa-css' }, SETTINGS_CSS),
491
+ React.createElement('div', { className: 'cpa-status' },
492
+ React.createElement(StateDot, { state: state ? statusDots[state.status] || 'error' : 'ongoing' }),
493
+ React.createElement('p', { className: 'cpa-status-text', role: 'status' }, 'CLIProxyAPI: ', t(state ? statusKeys[state.status] || 'statusError' : 'loading')),
494
+ ),
495
+ React.createElement('div', { className: 'cpa-actions' },
496
+ React.createElement(Button, { type: 'button', variant: 'outline', onClick: () => pluginNavigation.openList() }, t('openSettings')),
497
+ React.createElement('span', { className: 'cpa-note' }, t('chooseCPA')),
498
+ ),
499
+ )
500
+ }
501
+
502
+ function apply(ctx) {
503
+ const locale = ctx.locale
504
+ const scope = ctx.configForms.get(DISCOVERY_NS)
505
+ const t = locale.bind(SETTINGS_LOCALE_NS)
506
+ const pluginT = locale.bind(SETTINGS_SUMMARY_NS)
507
+ ctx.effect(() => locale.register(SETTINGS_LOCALE_NS, copy), 'dsh-provider-cpa: dictionaries')
508
+ ctx.effect(() => locale.register(SETTINGS_SUMMARY_NS, { en: { title: 'CLIProxyAPI', description: 'Connect to a CLIProxyAPI model gateway.' }, ru: { title: 'CLIProxyAPI', description: 'Подключение к шлюзу CLIProxyAPI.' }, zh: { title: 'CLIProxyAPI', description: '连接 CLIProxyAPI 模型网关。' } }), 'dsh-provider-cpa: Plugins page copy')
509
+ ctx.effect(() => ctx.configForms.whileServed([DISCOVERY_NS], () => ctx.slots.inject(SETTINGS_SLOT, () => ctx.slots.register({ name: SETTINGS_SLOT, id: SETTINGS_TAB_ID, order: 30, label: () => pluginT('title'), description: () => pluginT('description'), locale: SETTINGS_SUMMARY_NS, inject: () => ({ t, hooks: { scope } }) }, SettingsTab))), 'dsh-provider-cpa: settings page')
510
+ ctx.effect(() => ctx.slots.inject('settings.models.footer', () => ctx.slots.register({ name: 'settings.models.footer', id: 'cliproxyapi', inject: () => ({ t, pluginNavigation: ctx.pluginNavigation }) }, ModelsFooter)), 'dsh-provider-cpa: models footer')
511
+ }
512
+
513
+ exports.apply = apply
514
+ exports.inject = inject
515
+ return module.exports
516
+ },
517
+ })
@@ -0,0 +1,5 @@
1
+ # Install the CLIProxyAPI catalog discovery and browser settings integration.
2
+ # The existing pi-ai route remains in use until an explicit CPA cutover.
3
+ - insert:
4
+ - id: llm-cliproxyapi
5
+ name: "@thebtf/dsh-cliproxyapi-provider"
package/details/en.md ADDED
@@ -0,0 +1,9 @@
1
+ # CLIProxyAPI for DeepSeek Harness
2
+
3
+ Open **Plugins**, then select **CLIProxyAPI**. Choose the wire protocol your gateway exposes — OpenAI Responses, OpenAI Chat Completions, Anthropic Messages or Google Generative AI — then enter your actual endpoint and explicitly choose keep, replace, reference, or no authentication. HTTP is unencrypted. A changed endpoint cannot reuse saved credentials without consent.
4
+
5
+ Preview models, search or select all/deselect all, then save the connection, policy and model draft together. Zero selected models is valid but not ready. Details show metadata, source labels and advertised reasoning efforts. A stale catalog can remain available after a refresh failure. Save and activation are reported separately.
6
+
7
+ Disable retains the configuration and credential; forget-key is separate. Saving the first connection adopts an existing legacy profile automatically, with a reversible backup; **Import legacy connection** is still available for an explicit import. The plugin also keeps a copy of the last saved connection outside the profile: when a rebuild or a core gate removes the settings entry, **Restore saved connection** brings it back without retyping the address or key. Inspect recovery errors before retrying. Do not overwrite newer settings with an old rollback backup.
8
+
9
+ The Models footer opens Plugins; select CLIProxyAPI there. It does not provide a full managed Models row. Restart DSH after installing or updating. Requires Node 24.9.0 or newer and DSH 0.2.2-rc.3; `engines.dsh` accepts the rest of the 0.2.x line.
package/details/ru.md ADDED
@@ -0,0 +1,9 @@
1
+ # CLIProxyAPI для DeepSeek Harness
2
+
3
+ Откройте **Plugins** и выберите **CLIProxyAPI**. Выберите протокол обмена, который предоставляет шлюз (OpenAI Responses, OpenAI Chat Completions, Anthropic Messages или Google Generative AI), затем укажите фактический адрес и явно выберите действие с авторизацией: сохранить, заменить ключ, использовать ссылку или отключить авторизацию. HTTP не шифруется. Повторное использование ключа с новым адресом требует согласия.
4
+
5
+ Загрузите предпросмотр, найдите и выберите модели; массовые действия применяются ко всему каталогу. Сохранение включает адрес, авторизацию, политику и выбор. Ноль моделей допустим, но подключение не готово. Детали содержат метаданные, источник возможностей и заявленные уровни рассуждения. При ошибке обновления прежний каталог может быть показан как устаревший. Сохранение и активация показываются отдельно.
6
+
7
+ Отключение сохраняет настройки и ключ; удаление ключа выполняется отдельно. При сохранении первого подключения прежняя запись принимается автоматически с обратимой резервной копией; **Импорт прежнего подключения** по-прежнему доступен для явного импорта. Плагин также хранит копию последнего сохранённого подключения вне профиля: если запись настроек исчезла после пересборки профиля или блокировки по совместимости, кнопка **Восстановить сохранённое подключение** вернёт её без повторного ввода адреса и ключа. Изучите ошибки восстановления перед повтором. Не восстанавливайте старую копию поверх новых настроек.
8
+
9
+ Внизу Models можно открыть Plugins и затем выбрать CLIProxyAPI. Это не полноценная управляемая строка Models. После установки или обновления перезапустите DSH. Требуются Node от 24.9.0 и DSH 0.2.2-rc.3; `engines.dsh` допускает остальную линию 0.2.x.
package/details/zh.md ADDED
@@ -0,0 +1,9 @@
1
+ # DeepSeek Harness 的 CLIProxyAPI
2
+
3
+ 打开 **插件**,然后选择 **CLIProxyAPI**。先选择网关提供的通信协议(OpenAI Responses、OpenAI Chat Completions、Anthropic Messages 或 Google Generative AI),再输入实际地址,并明确选择保留、替换密钥、凭据引用或不验证。HTTP 不加密。更换地址后,重用保存的凭据需要明确同意。
4
+
5
+ 预览目录后搜索、选择或批量选择模型。保存同时提交连接、策略和模型草稿。允许零模型,但连接尚未就绪。详情展示元数据、来源和已声明的推理等级;刷新失败后,上次目录可能标为过期。保存和激活分别显示。
6
+
7
+ 停用会保留设置和凭据;忘记密钥是独立操作。保存第一个连接时会自动接管旧配置并保留可回退的备份;仍可显式选择导入旧连接。插件还在配置文件之外保留最后保存连接的副本:如果配置文件重建或核心兼容性门禁移除了设置条目,页面上的 **恢复已保存的连接** 可直接恢复,无需重新输入地址和密钥。重试前检查恢复错误。不要用旧备份覆盖较新设置。
8
+
9
+ Models 页底部可打开插件,然后选择 CLIProxyAPI;这不是完整的 Models 管理行。安装或更新后重启 DSH。需要 Node 24.9.0 及以上和 DSH 0.2.2-rc.3;`engines.dsh` 接受 0.2.x 的其余版本。
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "CLIProxyAPI",
4
+ "description": "Connect a CLIProxyAPI gateway and manage its models in DeepSeek Harness.",
5
+ "detail": "details/en.md"
6
+ }
7
+ }