diff --git a/AGENTS.md b/AGENTS.md index 7b0bd2e..e15fe1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,5 @@ - Codex CLI uses a user-level profile-v2 file, `https://codex.neuroapi.host/v1`, and WebSocket by default; the optional Codex Desktop setup uses the same host with HTTP/SSE. Publish installers only after authenticated catalog and transport checks. - Keep unsupported hosted Codex tools (`web_search`, multi-agent namespace, goals, apps and browser use) disabled in this profile until the server can execute and bill them safely; local file and shell tools must remain available. - Managed launchers fetch and validate fresh key-scoped catalogs into private per-launch snapshots; never fall back to stale/bundled lists or accept executable server settings. Claude Code's base URL is `https://claude.neuroapi.host`; Claude Desktop gateway is configured separately in its UI. Keep ordinary keys, preserve unrelated configuration, and document managed-policy/explicit-override boundaries. + +- Claude client-settings v2 is opt-in via `X-NeuroAPI-Client-Settings-Version: 2`; accept only the three reviewed limit/hint env keys with canonical bounded decimal strings (or hint `1`), retain the 4096 output fallback for old servers. `--doctor` performs no generation; `--doctor-generate` explicitly opts into one bounded HTTP probe with no retries, secret-free output and no claim of WebSocket/tool compatibility. diff --git a/README.en.md b/README.en.md index 12a7856..48a641b 100644 --- a/README.en.md +++ b/README.en.md @@ -58,7 +58,7 @@ This prevents accidental plaintext disclosure. It does not protect a key from ma - For Codex Desktop, check a new local task, `https://codex.neuroapi.host/v1` in the user config, and the request in your NeuroAPI usage logs. Setup uses HTTP/SSE for this GUI integration. - Check current model IDs and pricing at [neuroapi.host/price](https://neuroapi.host/price). -Each launcher fetches a fresh catalog scoped to the ordinary NeuroAPI key. Codex uses a private `model_catalog_json`; Claude receives a configured picker. There are no hardcoded default models. Tested minimum versions are Codex 0.158.0 and Claude Code 2.1.284. The Claude launcher limits initial output to 4096 tokens to bound quota reservation. Invalid, empty or unavailable catalogs stop launch instead of restoring stale lists. Organization policies and deliberate CLI overrides retain their documented precedence; these launchers do not support host-managed provider mode. +Each launcher fetches a fresh catalog scoped to the ordinary NeuroAPI key. Codex uses a private `model_catalog_json`; Claude receives a configured picker. There are no hardcoded default models. Tested minimum versions are Codex 0.158.0 and Claude Code 2.1.284. The Claude launcher requests v2 settings and accepts strictly validated context/output limits and gateway hint headers. Older servers retain the 4096-token output fallback; output limits are independent of financial reservation estimates. Invalid, empty or unavailable catalogs stop launch instead of restoring stale lists. Organization policies and deliberate CLI overrides retain their documented precedence; these launchers do not support host-managed provider mode. Recommended server selection: GPT-6 Sol, Astra and Luna for Codex; **Opus 5.5** (`claude-opus-5-5`, preferred), Sonnet 5.5, Sonnet 5 and Fable 5.1 for Claude Code. An entry appears only when its published model, tariff and compatible upstream route are available to the key. Existing administrator catalog settings override source defaults. @@ -76,3 +76,12 @@ Claude Code also uses the `haiku` alias for background work. If no recommended H - [Codex Desktop guide](https://neuroapi.host/docs/codex-desktop) - [Claude Desktop guide](https://neuroapi.host/docs/claude-desktop) - [NeuroAPI documentation](https://neuroapi.host/docs/getting-started) + +## Connection diagnostics + +Run `codex-neuroapi --doctor` or `claude-neuroapi --doctor` to validate the client version +and protected key's access to the current model catalog. The key is not printed and no +paid generation occurs. `--doctor-generate` explicitly requests one small **billable** HTTP +generation and measures its duration, without retries. It validates a completed nonempty +answer; it does not prove WebSocket, client tools, GUI sessions or deliberate overrides. +Organization provider policies remain authoritative. Temporary snapshots are removed. diff --git a/README.md b/README.md index 75cc54e..c201a03 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Claude Code: 2. Выполните `/status`. 3. Проверьте base URL `https://claude.neuroapi.host` и credential source `apiKeyHelper`. -При каждом запуске launcher получает актуальный список для вашего обычного ключа NeuroAPI. Codex использует отдельный каталог, Claude Code — настроенное меню; фиксированных моделей в установщике нет. Проверенные минимумы: Codex 0.158.0 и Claude Code 2.1.284. Для Claude Code установщик задаёт начальный лимит вывода 4096 токенов, чтобы запросы с большим стандартным лимитом не резервировали избыточную квоту. При ошибке обновления или пустом списке запуск останавливается, не возвращаясь к старым моделям. Подробности и ограничения managed-политик: [ручная настройка](docs/manual-setup.md). +При каждом запуске launcher получает актуальный список для вашего обычного ключа NeuroAPI. Codex использует отдельный каталог, Claude Code — настроенное меню; фиксированных моделей в установщике нет. Проверенные минимумы: Codex 0.158.0 и Claude Code 2.1.284. Claude Code получает проверенные сервером пределы контекста и вывода и служебные диагностические заголовки. Если сервер ещё не передаёт предел вывода, сохраняется совместимый лимит 4096 токенов. Лимит ответа независим от оценки финансового резерва. При ошибке обновления или пустом списке запуск останавливается, не возвращаясь к старым моделям. Подробности и ограничения managed-политик: [ручная настройка](docs/manual-setup.md). Рекомендуемый серверный набор: Codex — GPT-6 Sol, Astra и Luna; Claude Code — **Opus 5.5** (`claude-opus-5-5`, приоритетный), Sonnet 5.5, Sonnet 5 и Fable 5.1. Модель появляется в меню только после публикации на сервисе и появления совместимого маршрута для тарифа и ключа. Если администратор сохранил собственный список, он имеет приоритет над рекомендуемым набором. @@ -165,3 +165,18 @@ GitHub Actions выполняет: - поиск случайно добавленных секретов и небезопасных способов передачи ключа. Реальный пользовательский ключ никогда не нужен CI. + +## Проверить подключение + +В терминале выполните `codex-neuroapi --doctor` или `claude-neuroapi --doctor`. +Диагностика проверяет совместимость версии, доступ защищённого ключа к свежему каталогу +и показывает адрес и модель по умолчанию. Генерация не выполняется и токены не оплачиваются. +Ключ остаётся скрыт; временные каталоги удаляются. + +Для одного короткого **платного** API-запроса выполните `codex-neuroapi --doctor-generate` +или `claude-neuroapi --doctor-generate`. Этот режим проверяет законченный непустой ответ +по HTTP и показывает время ожидания; автоматических повторов нет. Он не проверяет +WebSocket, инструменты или текущую сессию GUI. Явные CLI overrides и политики организации +могут менять обычный запуск, поэтому doctor не подтверждает их работу. + +Если проверка не прошла, смотрите [устранение проблем](docs/troubleshooting.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bbde320..ddd5e6a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -86,3 +86,27 @@ Windows и macOS требуют точное подтверждение `DELETE` ## Сообщить об ошибке Обычные ошибки можно описать в GitHub Issues без API-ключа. Уязвимости и credential-handling проблемы отправляйте по [SECURITY.md](../SECURITY.md). + +## Диагностика без раскрытия ключа + +1. Откройте новый терминал и запустите `codex-neuroapi --doctor` либо `claude-neuroapi --doctor`. + Успех означает совместимую версию клиента и доступ вашего ключа к актуальному каталогу; + баланс генерации и WebSocket при этом не проверяются. +2. Для проверки реального ответа используйте `--doctor-generate` вместо `--doctor`. + Будет один короткий платный API-запрос с вашего баланса (ожидание до 180 секунд), + без автоматического повтора. Пустой, незаконченный ответ или одна статистика usage + не считаются успехом. Идентификатор модели в ответе должен точно совпадать с запрошенным; + отсутствующий или другой идентификатор даёт `model_identity_unverified`. Это может быть + сопоставление имени на сервисе, а не сетевой сбой. Сверка проверяет заявленное имя, + но не устанавливает подлинность модели провайдера. Роль сообщения должна быть `assistant`. Этот режим не запускает инструменты и не создаёт сессию клиента. +3. Если doctor проходит, а обычный запуск нет, проверьте явные `--model`, `--settings` + и `-c`, а также политики организации: doctor использует модель и адрес из управляемого + профиля, а параметры вашего запуска могут их переопределять. + +Не публикуйте содержимое Keychain/DPAPI, полный конфиг или API-ключ. Диагностику можно +передать поддержке: она выводит только адрес, модель, результат и время проверки. + +Claude Code теперь использует проверенные сервером лимиты вместо постоянного ограничения +вывода 4096 токенами. На старом сервере этот лимит остаётся запасным значением. +Не увеличивайте окно контекста вручную сверх доступного модели: серверная оценка резерва +не увеличивает контекст и не фиксирует окончательную стоимость запроса. diff --git a/scripts/macos/catalog-validator.js b/scripts/macos/catalog-validator.js index c740bda..374369a 100644 --- a/scripts/macos/catalog-validator.js +++ b/scripts/macos/catalog-validator.js @@ -12,7 +12,7 @@ function keys(value, allowed) { function text(value, limit) { return typeof value === 'string' && value.length <= limit && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value) } -function modelID(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value) } +function modelID(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value) && !/\s/.test(value) } function integer(value, min, max) { return Number.isSafeInteger(value) && value >= min && value <= max } function readBounded(handle, limit) { var data = $.NSMutableData.data @@ -83,9 +83,17 @@ function validateClaude(data) { if (option.description !== undefined) requireValue(text(option.description, 4096)) }) requireValue(options[data.model] === true) - keys(data.env, ['ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL']) + keys(data.env, ['ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', 'CLAUDE_CODE_GATEWAY_HINT_HEADERS']) Object.keys(data.env).forEach(function (key) { - requireValue(modelID(data.env[key]) && seen[data.env[key]]) + var value = data.env[key] + if (key === 'CLAUDE_CODE_GATEWAY_HINT_HEADERS') { requireValue(value === '1'); return } + if (key === 'CLAUDE_CODE_AUTO_COMPACT_WINDOW' || key === 'CLAUDE_CODE_MAX_OUTPUT_TOKENS') { + requireValue(typeof value === 'string' && /^[1-9][0-9]{0,6}$/.test(value) && String(Number(value)) === value) + requireValue(integer(Number(value), key === 'CLAUDE_CODE_AUTO_COMPACT_WINDOW' ? 100000 : 1, + key === 'CLAUDE_CODE_AUTO_COMPACT_WINDOW' ? 1000000 : 32000)) + return + } + requireValue(modelID(value) && seen[value]) var family = key.slice('ANTHROPIC_DEFAULT_'.length, -'_MODEL'.length).toLowerCase() if (family === 'haiku') { // Claude Code also uses the Haiku alias for background calls. The @@ -97,8 +105,28 @@ function validateClaude(data) { } }) } +function validateDoctor(data, client) { + requireValue(object(data) && !data.error) + var blocks + if (client === 'codex') { + requireValue(data.status === 'completed' && Array.isArray(data.output)) + blocks = [] + data.output.forEach(function (item) { + if (item.type === 'message' && item.role === 'assistant' && Array.isArray(item.content)) blocks = blocks.concat(item.content) + }) + } else { + requireValue(data.type === 'message' && data.role === 'assistant' && data.stop_reason === 'end_turn' && Array.isArray(data.content)) + blocks = data.content + } + // An empty 200, metadata or usage alone is not a working generation. + requireValue(blocks.some(function (block) { + return object(block) && block.type === (client === 'codex' ? 'output_text' : 'text') && + typeof block.text === 'string' && block.text.trim().length > 0 + })) + return 'Ответ ассистента получен по HTTP; заявленное имя модели совпадает. Эта проверка не проверяет WebSocket или инструменты клиента.' +} function run(args) { - requireValue(args.length === 3 && ['codex', 'claude'].indexOf(args[0]) !== -1) + requireValue(args.length === 3 && ['codex', 'claude', 'doctor-codex', 'doctor-claude'].indexOf(args[0]) !== -1) var raw = readBounded($.NSFileHandle.fileHandleWithStandardInput, 4194310) var secretHandle = $.NSFileHandle.fileHandleForReadingAtPath('/dev/fd/3') requireValue(!secretHandle.isNil()) @@ -107,6 +135,18 @@ function run(args) { requireValue(raw.slice(-4) === '\n200') var data = JSON.parse(raw.slice(0, -4)) requireValue(JSON.stringify(data).indexOf(secret) === -1) + if (args[0].indexOf('doctor-') === 0) { + var modelHandle = $.NSFileHandle.fileHandleForReadingAtPath($(args[1] + '/model.txt')) + requireValue(!modelHandle.isNil()) + var expectedModel = readBounded(modelHandle, 257).trim() + requireValue(modelID(expectedModel)) + requireValue(object(data) && !data.error) + if (data.model !== expectedModel) { + write(args[1] + '/doctor-error.txt', 'model_identity_unverified') + requireValue(false) + } + return validateDoctor(data, args[0].slice(7)) + } if (args[0] === 'codex') { validateCodex(data) write(args[1] + '/models.json', JSON.stringify({models: data.models})) @@ -123,10 +163,11 @@ function run(args) { }) data.env.ANTHROPIC_BASE_URL = 'https://claude.neuroapi.host' data.env.ANTHROPIC_MODEL = data.model - // Keep first-request quota reservations bounded. Claude Code can continue - // generation in another turn when a response reaches this limit. - data.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '4096' + // v1 servers provide no verified limits. v2 output limits are independent + // of financial reservation estimates and must survive validation. + if (data.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === undefined) data.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '4096' data.apiKeyHelper = "'" + args[2].replace(/'/g, "'\\''") + "'" write(args[1] + '/settings.json', JSON.stringify(data)) + write(args[1] + '/model.txt', data.model + '\n') } } diff --git a/scripts/macos/launch-managed.sh b/scripts/macos/launch-managed.sh index 95dbcca..d77845b 100755 --- a/scripts/macos/launch-managed.sh +++ b/scripts/macos/launch-managed.sh @@ -16,8 +16,17 @@ case "$CLIENT" in esac VERIFY_ONLY=0 +DOCTOR=0 +DOCTOR_GENERATE=0 EXPORT_CATALOG_ROOT='' -if [[ "${1:-}" == '--verify' ]]; then +if [[ "${1:-}" == '--doctor' || "${1:-}" == '--doctor-generate' ]]; then + DOCTOR=1 + [[ "$1" != '--doctor-generate' ]] || DOCTOR_GENERATE=1 + shift + [[ $# == 0 ]] || { printf 'Диагностика не принимает дополнительные параметры.\n' >&2; exit 1; } +elif [[ "${1:-}" == --doctor* ]]; then + printf 'Используйте --doctor или --doctor-generate.\n' >&2; exit 1 +elif [[ "${1:-}" == '--verify' ]]; then VERIFY_ONLY=1 shift elif [[ "${1:-}" == '--export-codex-catalog' && "$CLIENT" == 'codex' && -n "${2:-}" ]]; then @@ -42,6 +51,10 @@ if ! client_version_at_least "$CLIENT_BIN" "$MIN_MAJOR" "$MIN_MINOR" "$MIN_PATCH fail "Требуется $CLIENT версии $MIN_MAJOR.$MIN_MINOR.$MIN_PATCH или новее." fi +if (( DOCTOR_GENERATE )); then + printf 'Проверка выполнит один короткий платный API-запрос с вашего баланса; повторов не будет (до 180 секунд).\n' +fi + SNAPSHOT="$(mktemp -d "${TMPDIR:-/tmp}/neuroapi-models.XXXXXX")" CHILD_PID='' cleanup() { rm -rf -- "$SNAPSHOT"; } @@ -80,10 +93,14 @@ if [[ ${#credential} -gt 4096 || ! "$credential" =~ ^[A-Za-z0-9._~-]+$ ]]; then unset credential fail 'Ключ NeuroAPI имеет неподдерживаемый формат. Повторите настройку.' fi +CATALOG_HEADERS=(--header 'Accept: application/json') +if [[ "$CLIENT" == 'claude' ]]; then + CATALOG_HEADERS+=(--header 'X-NeuroAPI-Client-Settings-Version: 2') +fi if ! printf 'header = "Authorization: Bearer %s"\n' "$credential" | "$CURL_BIN" --disable --config - --silent --fail --proto '=https' \ --proto-redir '=https' --max-redirs 0 --connect-timeout 5 --max-time 20 \ - --max-filesize 4194304 --header 'Accept: application/json' \ + --max-filesize 4194304 "${CATALOG_HEADERS[@]}" \ --write-out '\n%{http_code}' "$ENDPOINT" 2>/dev/null | /usr/bin/osascript -l JavaScript "$SCRIPT_DIR/catalog-validator.js" \ "$CLIENT" "$SNAPSHOT" "$SCRIPT_DIR/get-neuroapi-key.sh" \ @@ -91,6 +108,40 @@ if ! printf 'header = "Authorization: Bearer %s"\n' "$credential" | unset credential fail 'Не удалось загрузить актуальные модели NeuroAPI. Проверьте доступ ключа и соединение; старый список не используется.' fi +if (( DOCTOR )); then + IFS= read -r DEFAULT_MODEL <"$SNAPSHOT/model.txt" + printf 'Клиент %s: версия совместима. Ключ: скрыт; доступ к актуальному каталогу подтверждён.\n' "$CLIENT" + printf 'Адрес каталога: %s\nМодель по умолчанию: %s\n' "$ENDPOINT" "$DEFAULT_MODEL" + if (( DOCTOR_GENERATE )); then + if [[ "$CLIENT" == 'codex' ]]; then + GENERATION_ENDPOINT='https://codex.neuroapi.host/v1/responses' + printf '{"model":"%s","input":"Reply with NEUROAPI_OK only.","store":false,"max_output_tokens":256,"reasoning":{"effort":"low"}}' "$DEFAULT_MODEL" >"$SNAPSHOT/request.json" + else + GENERATION_ENDPOINT='https://claude.neuroapi.host/v1/messages' + printf '{"model":"%s","messages":[{"role":"user","content":"Reply with NEUROAPI_OK only."}],"max_tokens":64}' "$DEFAULT_MODEL" >"$SNAPSHOT/request.json" + fi + START_SECONDS=$SECONDS + if ! printf 'header = "Authorization: Bearer %s"\n' "$credential" | + "$CURL_BIN" --disable --config - --silent --fail --proto '=https' \ + --max-redirs 0 --connect-timeout 5 --max-time 180 --max-filesize 4194304 \ + --header 'Content-Type: application/json' --header 'anthropic-version: 2023-06-01' \ + --request POST --data-binary "@$SNAPSHOT/request.json" --write-out '\n%{http_code}' "$GENERATION_ENDPOINT" 2>/dev/null | + /usr/bin/osascript -l JavaScript "$SCRIPT_DIR/catalog-validator.js" \ + "doctor-$CLIENT" "$SNAPSHOT" "$SCRIPT_DIR/get-neuroapi-key.sh" \ + 3< <(printf '%s' "$credential") 2>/dev/null; then + unset credential + if [[ -f "$SNAPSHOT/doctor-error.txt" ]]; then + fail 'Идентификатор модели не подтверждён (model_identity_unverified). Возможно сопоставление имени; проверьте настройки модели.' + fi + fail 'Генерация не подтверждена. Проверьте баланс и доступность модели; повтор автоматически не выполняется.' + fi + printf 'Время API-запроса: %s секунд.\n' "$((SECONDS - START_SECONDS))" + else + printf 'Генерация не выполнялась. Для платной проверки ответа используйте --doctor-generate.\n' + fi + unset credential + exit 0 +fi unset credential if [[ -n "$EXPORT_CATALOG_ROOT" ]]; then diff --git a/scripts/windows/launch-neuroapi.ps1 b/scripts/windows/launch-neuroapi.ps1 index 8618339..5874eed 100644 --- a/scripts/windows/launch-neuroapi.ps1 +++ b/scripts/windows/launch-neuroapi.ps1 @@ -15,6 +15,9 @@ try { if ($_.Exception.Message -ceq 'Провайдер Claude Code управляется организацией. Используйте настройки организации; запуск NeuroAPI отменен.') { $message = $_.Exception.Message } + if ($_.Exception.Message -cin @('Идентификатор модели не подтверждён (model_identity_unverified). Возможно сопоставление имени; проверьте настройки модели.', 'Используйте --doctor или --doctor-generate без дополнительных параметров.', 'Генерация не подтверждена. Проверьте баланс и доступность модели; повтор автоматически не выполняется.')) { + $message = $_.Exception.Message + } if ($_.Exception.Message -match '^Обновите (codex|claude) до версии [0-9.]+ или новее\. Запуск отменен\.$') { $message = $_.Exception.Message } diff --git a/scripts/windows/managed-catalog.ps1 b/scripts/windows/managed-catalog.ps1 index 599e760..222d01e 100644 --- a/scripts/windows/managed-catalog.ps1 +++ b/scripts/windows/managed-catalog.ps1 @@ -79,6 +79,7 @@ function Get-NeuroAPICatalogJson { $credential = Read-NeuroAPIProtectedCredential -SecretPath $SecretPath $request.Headers.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('Bearer', $credential) $request.Headers.Accept.ParseAdd('application/json') + if ($Client -eq 'claude') { $request.Headers.Add('X-NeuroAPI-Client-Settings-Version', '2') } $response = $http.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult() if ([int]$response.StatusCode -ne 200) { throw 'Catalog unavailable' } $limit = 2 * 1024 * 1024 @@ -113,7 +114,7 @@ function Get-NeuroAPICatalogJson { function Test-NeuroAPIModelId { param($Value) - return ($Value -is [string] -and $Value -cmatch '^[A-Za-z0-9][A-Za-z0-9._:/\-]{0,255}$') + return ($Value -is [string] -and $Value -cmatch '^[A-Za-z0-9][A-Za-z0-9._:/\-]{0,255}\z') } function Get-NeuroAPIProperty { @@ -261,7 +262,21 @@ function ConvertFrom-NeuroAPICatalog { } foreach ($name in (Get-NeuroAPIClaudeEnvironmentOverrides)) { $safeEnv[$name] = '' } $environment = Get-NeuroAPIProperty $data 'env' - Assert-NeuroAPIKeys $environment @('ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL') + Assert-NeuroAPIKeys $environment @('ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', 'CLAUDE_CODE_GATEWAY_HINT_HEADERS') + foreach ($key in @('CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', 'CLAUDE_CODE_GATEWAY_HINT_HEADERS')) { + $value = Get-NeuroAPIProperty $environment $key + if ($null -ne $environment.PSObject.Properties[$key]) { + if ($key -eq 'CLAUDE_CODE_GATEWAY_HINT_HEADERS') { + if ($value -isnot [string] -or $value -cne '1') { throw 'Invalid gateway hints' } + } else { + if ($value -isnot [string] -or $value -cnotmatch '^[1-9][0-9]{0,6}\z') { throw 'Invalid model limit' } + $minimum = if ($key -eq 'CLAUDE_CODE_AUTO_COMPACT_WINDOW') { 100000 } else { 1 } + $maximum = if ($key -eq 'CLAUDE_CODE_AUTO_COMPACT_WINDOW') { 1000000 } else { 32000 } + if (-not (Test-NeuroAPIInteger ([long]$value) $minimum $maximum)) { throw 'Invalid model limit' } + } + $safeEnv[$key] = $value + } + } foreach ($family in @('SONNET', 'OPUS', 'HAIKU', 'FABLE')) { $key = 'ANTHROPIC_DEFAULT_' + $family + '_MODEL' $value = Get-NeuroAPIProperty $environment $key @@ -331,13 +346,32 @@ function Invoke-NeuroAPIManagedClient { if ($Client -eq 'claude' -and ([string]$env:CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).Trim() -match '^(1|true|yes|on)$') { throw 'Провайдер Claude Code управляется организацией. Используйте настройки организации; запуск NeuroAPI отменен.' } + $doctor = $ClientArguments.Count -gt 0 -and $ClientArguments[0] -cin @('--doctor', '--doctor-generate') + $doctorGenerate = $doctor -and $ClientArguments[0] -ceq '--doctor-generate' + if ($ClientArguments.Count -gt 0 -and $ClientArguments[0] -clike '--doctor*') { + if (-not $doctor -or $ClientArguments.Count -ne 1) { throw 'Используйте --doctor или --doctor-generate без дополнительных параметров.' } + } $command = Get-NeuroAPIClientCommand $Client Assert-NeuroAPIClientVersion -Client $Client -Command $command $secretPath = Join-Path $StateRoot 'secret/api-key.dpapi' $helperPath = Join-Path $StateRoot 'bin/get-neuroapi-key.ps1' $helperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $helperPath + '" -SecretPath "' + $secretPath + '"' + if ($doctorGenerate) { Write-Host 'Проверка выполнит один короткий платный API-запрос с вашего баланса; повторов не будет (до 180 секунд).' } $json = Get-NeuroAPICatalogJson -Client $Client -SecretPath $secretPath $catalog = ConvertFrom-NeuroAPICatalog -Client $Client -Json $json -HelperCommand $helperCommand + if ($doctor) { + $defaultModel = if ($Client -eq 'codex') { $catalog.DefaultModel } else { $catalog.model } + $endpoint = if ($Client -eq 'codex') { 'https://codex.neuroapi.host/v1' } else { 'https://claude.neuroapi.host' } + Write-Host "Клиент ${Client}: версия совместима. Ключ: скрыт; доступ к актуальному каталогу подтверждён." + Write-Host "Адрес API: $endpoint`nМодель по умолчанию: $defaultModel" + if ($doctorGenerate) { + Invoke-NeuroAPIDoctorGeneration -Client $Client -Model $defaultModel -SecretPath $secretPath + } else { + Write-Host 'Генерация не выполнялась. Для платной проверки ответа используйте --doctor-generate.' + } + $script:NeuroAPIChildExitCode = 0 + return + } $launchRoot = $null $savedEnvironment = @{} try { @@ -372,3 +406,77 @@ function Invoke-NeuroAPIManagedClient { if ($null -ne $launchRoot -and (Test-Path -LiteralPath $launchRoot)) { Remove-Item -LiteralPath $launchRoot -Recurse -Force } } } + +function Invoke-NeuroAPIDoctorGeneration { + param([ValidateSet('codex', 'claude')][string]$Client, [string]$Model, [string]$SecretPath) + if (-not (Test-NeuroAPIModelId $Model)) { throw 'Некорректная модель диагностики.' } + $endpoint = if ($Client -eq 'codex') { 'https://codex.neuroapi.host/v1/responses' } else { 'https://claude.neuroapi.host/v1/messages' } + $body = if ($Client -eq 'codex') { + @{ model = $Model; input = 'Reply with NEUROAPI_OK only.'; store = $false; max_output_tokens = 256; reasoning = @{ effort = 'low' } } + } else { + @{ model = $Model; messages = @(@{ role = 'user'; content = 'Reply with NEUROAPI_OK only.' }); max_tokens = 64 } + } + $http = $null; $request = $null; $response = $null; $stream = $null; $buffer = $null; $timeout = $null + $credential = $null + $clock = [Diagnostics.Stopwatch]::StartNew() + try { + $http = New-NeuroAPICatalogClient + $http.Timeout = [TimeSpan]::FromSeconds(180) + $timeout = New-Object Threading.CancellationTokenSource + $timeout.CancelAfter(180000) + $request = New-Object Net.Http.HttpRequestMessage([Net.Http.HttpMethod]::Post, $endpoint) + $credential = Read-NeuroAPIProtectedCredential -SecretPath $SecretPath + $request.Headers.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('Bearer', $credential) + $request.Headers.Add('anthropic-version', '2023-06-01') + $request.Content = New-Object Net.Http.StringContent(($body | ConvertTo-Json -Depth 8 -Compress), [Text.Encoding]::UTF8, 'application/json') + $response = $http.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult() + if ([int]$response.StatusCode -ne 200) { throw 'Generation unavailable' } + $limit = 2 * 1024 * 1024 + if ($null -ne $response.Content.Headers.ContentLength -and $response.Content.Headers.ContentLength -gt $limit) { throw 'Response too large' } + $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $buffer = New-Object IO.MemoryStream + $chunk = New-Object byte[] 8192 + while ($true) { + $read = $stream.ReadAsync($chunk, 0, $chunk.Length, $timeout.Token).GetAwaiter().GetResult() + if ($read -eq 0) { break } + if ($buffer.Length + $read -gt $limit) { throw 'Response too large' } + $buffer.Write($chunk, 0, $read) + } + $utf8 = New-Object Text.UTF8Encoding($false, $true) + $json = $utf8.GetString($buffer.ToArray()) + if ($json.IndexOf($credential, [StringComparison]::Ordinal) -ge 0) { throw 'Credential reflected' } + $data = $json | ConvertFrom-Json -ErrorAction Stop + Assert-NeuroAPINoCredential -Value $data -Credential $credential -Depth 0 + if ($data -isnot [pscustomobject] -or $null -ne (Get-NeuroAPIProperty $data 'error')) { throw 'Invalid generation' } + if ((Get-NeuroAPIProperty $data 'model') -cne $Model) { throw 'model_identity_unverified' } + $blocks = @() + if ($Client -eq 'codex') { + if ((Get-NeuroAPIProperty $data 'status') -cne 'completed' -or (Get-NeuroAPIProperty $data 'output') -isnot [array]) { throw 'Incomplete generation' } + foreach ($item in $data.output) { + if ((Get-NeuroAPIProperty $item 'type') -ceq 'message' -and (Get-NeuroAPIProperty $item 'role') -ceq 'assistant' -and (Get-NeuroAPIProperty $item 'content') -is [array]) { $blocks += @($item.content) } + } + } else { + if ((Get-NeuroAPIProperty $data 'type') -cne 'message' -or (Get-NeuroAPIProperty $data 'role') -cne 'assistant' -or (Get-NeuroAPIProperty $data 'stop_reason') -cne 'end_turn' -or (Get-NeuroAPIProperty $data 'content') -isnot [array]) { throw 'Incomplete generation' } + $blocks = @($data.content) + } + $textType = if ($Client -eq 'codex') { 'output_text' } else { 'text' } + $textBlocks = @($blocks | Where-Object { + (Get-NeuroAPIProperty $_ 'type') -ceq $textType -and + (Get-NeuroAPIProperty $_ 'text') -is [string] -and -not [string]::IsNullOrWhiteSpace($_.text) + }) + if ($textBlocks.Count -eq 0) { throw 'Empty generation' } + Write-Host 'Ответ ассистента получен по HTTP; заявленное имя модели совпадает. Эта проверка не проверяет WebSocket или инструменты клиента.' + Write-Host ('Время API-запроса: {0:N1} секунд.' -f $clock.Elapsed.TotalSeconds) + } catch { + if ($_.Exception.Message -ceq 'model_identity_unverified') { + throw 'Идентификатор модели не подтверждён (model_identity_unverified). Возможно сопоставление имени; проверьте настройки модели.' + } + throw 'Генерация не подтверждена. Проверьте баланс и доступность модели; повтор автоматически не выполняется.' + } finally { + $clock.Stop() + $credential = $null + foreach ($resource in @($buffer, $stream, $response, $request, $timeout, $http)) { + if ($null -ne $resource) { $resource.Dispose() } + } + } +} diff --git a/tests/macos/catalog_smoke.py b/tests/macos/catalog_smoke.py index 1b803c8..3032440 100644 --- a/tests/macos/catalog_smoke.py +++ b/tests/macos/catalog_smoke.py @@ -45,16 +45,26 @@ def claude_catalog(slug="claude-opus-5-5"): assert 'test-neuroapi-token' not in ' '.join(args) assert args[:3] == ['--disable', '--config', '-'] assert '--location' not in args and '-L' not in args -assert args[args.index('--max-time')+1] == '20' +assert args[args.index('--max-time')+1] == ('180' if '--request' in args else '20') assert args[args.index('--max-filesize')+1] == '4194304' assert args[args.index('--proto')+1] == '=https' -assert args[-1] in ['https://codex.neuroapi.host/v1/models', 'https://claude.neuroapi.host/client-settings'] +assert args[-1] in ['https://codex.neuroapi.host/v1/models', 'https://claude.neuroapi.host/client-settings', 'https://codex.neuroapi.host/v1/responses', 'https://claude.neuroapi.host/v1/messages'] +if args[-1].endswith('/client-settings'): + assert 'X-NeuroAPI-Client-Settings-Version: 2' in args pathlib.Path(os.environ['TEST_FETCH_MARKER']).write_text('fetch') if os.environ.get('TEST_FETCH_FAIL'): print('private upstream error') sys.exit(22) -sys.stdout.write(pathlib.Path(os.environ['TEST_PAYLOAD']).read_text()) -sys.stdout.write('\\n' + os.environ.get('TEST_STATUS', '200')) +if '--request' in args: + assert args[args.index('--request')+1] == 'POST' + payload = json.loads(pathlib.Path(args[args.index('--data-binary')+1][1:]).read_text()) + assert 'test-neuroapi-token' not in json.dumps(payload) + assert payload.get('max_tokens', payload.get('max_output_tokens')) in (64, 256) + sys.stdout.write(pathlib.Path(os.environ['TEST_GENERATION_PAYLOAD']).read_text()) + sys.stdout.write('\\n' + os.environ.get('TEST_GENERATION_STATUS', '200')) +else: + sys.stdout.write(pathlib.Path(os.environ['TEST_PAYLOAD']).read_text()) + sys.stdout.write('\\n' + os.environ.get('TEST_STATUS', '200')) ''') curl.chmod(0o700) # The subprocess mock interpreter uses an explicit test-only PATH entry. @@ -77,7 +87,7 @@ def claude_catalog(slug="claude-opus-5-5"): path = pathlib.Path(a[a.index('--settings') + 1]) payload = json.loads(path.read_text()) assert payload['env']['ANTHROPIC_BASE_URL'] == 'https://claude.neuroapi.host' - assert payload['env']['CLAUDE_CODE_MAX_OUTPUT_TOKENS'] == '4096' + assert payload['env']['CLAUDE_CODE_MAX_OUTPUT_TOKENS'] == os.environ.get('TEST_EXPECT_OUTPUT', '4096') assert 'apiKeyHelper' in payload and 'test-neuroapi-token' not in path.read_text() neutralized = ['ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'ANTHROPIC_DEFAULT_MODEL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', @@ -152,10 +162,49 @@ def run(payload, client="codex", success=True, user_args=None, **extra): text=True, capture_output=True, timeout=30) assert verified.returncode == 0 and verified.stdout == 'Каталог codex проверен.\n', verified.stderr assert not list(verify_scratch.iterdir()), 'Verify left a private snapshot' + for client in ['codex', 'claude']: + verify_payload.write_text(json.dumps(catalog() if client == 'codex' else claude_catalog())) + doctor = subprocess.run([str(launchers / (client + '-neuroapi')), '--doctor'], + env=dict(base_env, TMPDIR=str(verify_scratch), TEST_PAYLOAD=str(verify_payload), + TEST_FETCH_MARKER=str(root / 'doctor-fetch')), + text=True, capture_output=True, timeout=30) + assert doctor.returncode == 0 and 'Генерация не выполнялась.' in doctor.stdout, doctor.stderr + assert 'Ключ: скрыт' in doctor.stdout and 'test-neuroapi-token' not in doctor.stdout + doctor.stderr + assert not list(verify_scratch.iterdir()), 'Doctor left private snapshot' + generation_fixture = root / 'doctor-generation.json' + for client, response in [ + ('codex', {'model':'test-model', 'status':'completed', 'output':[{'type':'message','role':'assistant','content':[{'type':'output_text','text':'NEUROAPI_OK'}]}]}), + ('claude', {'model':'claude-opus-5-5', 'type':'message', 'role':'assistant', 'stop_reason':'end_turn', 'content':[{'type':'text','text':'NEUROAPI_OK'}]}) + ]: + verify_payload.write_text(json.dumps(catalog() if client == 'codex' else claude_catalog())) + for invalidCase in ['valid', 'empty', 'missing_model', 'wrong_model', 'user_role']: + invalid = invalidCase != 'valid' + candidate = dict(response) + if invalidCase == 'empty': candidate = {'usage': {'input_tokens': 10}} + elif invalidCase == 'missing_model': candidate.pop('model') + elif invalidCase == 'wrong_model': candidate['model'] = 'unexpected-model' + elif invalidCase == 'user_role': + candidate = json.loads(json.dumps(response)) + if client == 'claude': candidate['role'] = 'user' + else: candidate['output'][0]['role'] = 'user' + generation_fixture.write_text(json.dumps(candidate)) + generated = subprocess.run([str(launchers / (client + '-neuroapi')), '--doctor-generate'], + env=dict(base_env, TMPDIR=str(verify_scratch), TEST_PAYLOAD=str(verify_payload), + TEST_FETCH_MARKER=str(root / 'doctor-fetch'), TEST_GENERATION_PAYLOAD=str(generation_fixture)), + text=True, capture_output=True, timeout=30) + assert (generated.returncode != 0) == invalid, generated.stderr + assert 'платный API-запрос' in generated.stdout + assert 'test-neuroapi-token' not in generated.stdout + generated.stderr + assert ('Ответ ассистента получен' in generated.stdout) != invalid + if invalidCase in ('missing_model', 'wrong_model'): assert 'model_identity_unverified' in generated.stderr + assert not list(verify_scratch.iterdir()), 'Doctor generation retained files' + assert not run(catalog(), success=False, user_args=['--doctor-bad']) + assert not run(catalog(), success=False, user_args=['--doctor', '--model', 'x']) second = run(catalog("changed-model")) assert second["payload"]["models"][0]["slug"] == "changed-model" assert first["path"] != second["path"] run(catalog(), user_args=['--model', 'explicit-user-model']) + run(catalog('model-with-newline' + chr(10)), success=False) run(claude_catalog(), client="claude") sonnet_background = claude_catalog() sonnet_background['availableModels'].append('claude-sonnet-5-5') @@ -167,6 +216,23 @@ def run(payload, client="codex", success=True, user_args=None, **extra): default_background = claude_catalog() default_background['env']['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = default_background['model'] run(default_background, client="claude") + v2 = claude_catalog() + v2['env'].update(CLAUDE_CODE_AUTO_COMPACT_WINDOW='200000', CLAUDE_CODE_MAX_OUTPUT_TOKENS='32000', CLAUDE_CODE_GATEWAY_HINT_HEADERS='1') + upgraded = run(v2, client='claude', TEST_EXPECT_OUTPUT='32000') + assert upgraded['payload']['env']['CLAUDE_CODE_AUTO_COMPACT_WINDOW'] == '200000' + assert upgraded['payload']['env']['CLAUDE_CODE_GATEWAY_HINT_HEADERS'] == '1' + for key, invalid in [ + ('CLAUDE_CODE_AUTO_COMPACT_WINDOW', '99999'), ('CLAUDE_CODE_AUTO_COMPACT_WINDOW', '1000001'), + ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', '0'), ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', '32001'), + ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', '01'), ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', '32_000'), + ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', '1e3'), ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', '32000' + chr(10)), + ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', 32000), ('CLAUDE_CODE_MAX_OUTPUT_TOKENS', None), + ('CLAUDE_CODE_GATEWAY_HINT_HEADERS', 'true'), ('CLAUDE_CODE_GATEWAY_HINT_HEADERS', 1), + ('CLAUDE_CODE_GATEWAY_HINT_HEADERS', '0'), ('CLAUDE_CODE_GATEWAY_HINT_HEADERS', '1;echo unsafe') + ]: + rejected = claude_catalog() + rejected['env'][key] = invalid + run(rejected, client='claude', success=False) security_log = Path(os.environ['NEUROAPI_AGENTS_SECURITY_LOG']) for host_managed in ['1', 'true', 'yes', 'on', ' TRUE ', '\tOn\n', ' YeS ', ' 1 ']: before = security_log.read_bytes() diff --git a/tests/windows/managed-catalog.ps1 b/tests/windows/managed-catalog.ps1 index 5352946..ad0ce67 100644 --- a/tests/windows/managed-catalog.ps1 +++ b/tests/windows/managed-catalog.ps1 @@ -1,4 +1,4 @@ -# These deterministic tests run on PowerShell 5.1 (Windows) and pwsh (other OS). +# These deterministic tests run on PowerShell 5.1 (Windows) and pwsh (other OS). # Credential/network/process seams are mocked; no real keys or provider calls. Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -54,6 +54,25 @@ Assert-Catalog (-not $claude.env.Contains('UNSAFE_ENV')) 'Unreviewed environment Assert-Catalog (-not $claude.env.Contains('ANTHROPIC_DEFAULT_FABLE_MODEL')) 'Missing Fable was invented.' Assert-Catalog ($claude.availableModels.Count -eq 5 -and $claude.modelPicker.options.Count -eq 3) 'Compatibility aliases must remain available but hidden.' Assert-Catalog ($claude.fallbackModel.Count -eq 0) 'Inherited fallback was not disabled.' +$v2 = New-TestClaudeCatalog | ConvertFrom-Json +$v2.env | Add-Member -NotePropertyName CLAUDE_CODE_AUTO_COMPACT_WINDOW -NotePropertyValue '200000' +$v2.env | Add-Member -NotePropertyName CLAUDE_CODE_MAX_OUTPUT_TOKENS -NotePropertyValue '32000' +$v2.env | Add-Member -NotePropertyName CLAUDE_CODE_GATEWAY_HINT_HEADERS -NotePropertyValue '1' +$upgraded = ConvertFrom-NeuroAPICatalog claude ($v2 | ConvertTo-Json -Depth 8 -Compress) 'local-helper' +Assert-Catalog ($upgraded.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS -ceq '32000' -and $upgraded.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW -ceq '200000' -and $upgraded.env.CLAUDE_CODE_GATEWAY_HINT_HEADERS -ceq '1') 'Verified v2 limits were clamped or discarded.' +foreach ($invalid in @( + @{Key='CLAUDE_CODE_AUTO_COMPACT_WINDOW';Value='99999'}, @{Key='CLAUDE_CODE_AUTO_COMPACT_WINDOW';Value='1000001'}, + @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value='0'}, @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value='32001'}, + @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value='01'}, @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value='32_000'}, + @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value='1e3'}, @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value="32000`n"}, + @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value=32000}, @{Key='CLAUDE_CODE_MAX_OUTPUT_TOKENS';Value=$null}, + @{Key='CLAUDE_CODE_GATEWAY_HINT_HEADERS';Value='0'}, @{Key='CLAUDE_CODE_GATEWAY_HINT_HEADERS';Value='true'}, + @{Key='CLAUDE_CODE_GATEWAY_HINT_HEADERS';Value=1}, @{Key='CLAUDE_CODE_GATEWAY_HINT_HEADERS';Value='1;echo unsafe'} +)) { + $invalidCatalog = New-TestClaudeCatalog | ConvertFrom-Json + $invalidCatalog.env | Add-Member -NotePropertyName $invalid.Key -NotePropertyValue $invalid.Value + Assert-CatalogFailure { ConvertFrom-NeuroAPICatalog claude ($invalidCatalog | ConvertTo-Json -Depth 8 -Compress) 'local-helper' } +} $sonnetBackground = New-TestClaudeCatalog | ConvertFrom-Json $sonnetBackground.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-sonnet-5' $sonnetSettings = ConvertFrom-NeuroAPICatalog claude ($sonnetBackground | ConvertTo-Json -Depth 8 -Compress) 'local-helper' @@ -62,6 +81,7 @@ $opusBackground = New-TestClaudeCatalog | ConvertFrom-Json $opusBackground.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-opus-5-5' $opusSettings = ConvertFrom-NeuroAPICatalog claude ($opusBackground | ConvertTo-Json -Depth 8 -Compress) 'local-helper' Assert-Catalog ($opusSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL -ceq 'claude-opus-5-5') 'Recommended default background fallback was rejected.' +Assert-CatalogFailure { ConvertFrom-NeuroAPICatalog codex (New-TestCodexCatalog "model-with-newline`n") '' } foreach ($bad in @('{', '{}', '{"models":[],"default_model":"model-one"}', '{"models":[{"slug":"model-one","display_name":"one"}],"default_model":"missing"}', '{"models":[{"slug":"bad model","display_name":"one"}],"default_model":"bad model"}')) { Assert-CatalogFailure { ConvertFrom-NeuroAPICatalog codex $bad '' } } @@ -114,13 +134,19 @@ public sealed class NeuroAPITestHttpHandler : HttpMessageHandler { public string Body = "{}"; public string Uri; public string Authorization; + public string SettingsVersion; + public string Method; + public string RequestBody; public bool Fail; public bool MisleadingLength; public int Calls; protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) { Calls++; Uri = request.RequestUri.ToString(); + Method = request.Method.Method; + RequestBody = request.Content == null ? "" : request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); Authorization = request.Headers.Authorization.ToString(); + SettingsVersion = request.Headers.Contains("X-NeuroAPI-Client-Settings-Version") ? string.Join(",", request.Headers.GetValues("X-NeuroAPI-Client-Settings-Version")) : ""; if (Fail) throw new HttpRequestException("private-provider-body test-neuroapi-token"); var response = new HttpResponseMessage((HttpStatusCode)Status); response.Content = new StringContent(Body); @@ -142,6 +168,8 @@ foreach ($client in @('codex', 'claude')) { Assert-Catalog ($script:mockHttp.Authorization -ceq 'Bearer test-neuroapi-token') 'Authorization missing.' $expected = if ($client -eq 'codex') { 'https://codex.neuroapi.host/v1/models' } else { 'https://claude.neuroapi.host/client-settings' } Assert-Catalog ($script:mockHttp.Uri -ceq $expected) 'Wrong catalog endpoint.' + $expectedVersion = if ($client -eq 'claude') { '2' } else { '' } + Assert-Catalog ($script:mockHttp.SettingsVersion -ceq $expectedVersion) 'Wrong settings version negotiation.' } foreach ($status in @(301, 302, 401, 403, 429, 503)) { $script:mockHttp = New-Object NeuroAPITestHttpHandler @@ -165,6 +193,48 @@ foreach ($echoBody in @('{"display_name":"test-neuroapi-token"}', '{"display_nam $script:mockHttp.Body = $echoBody Assert-CatalogFailure { Get-NeuroAPICatalogJson codex 'unused' } } +foreach ($generationCase in @( + @{Client='codex';Body='{"model":"test-model","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"NEUROAPI_OK"}]}]}'}, + @{Client='claude';Body='{"model":"test-model","type":"message","role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"NEUROAPI_OK"}]}'} +)) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Body = $generationCase.Body + Invoke-NeuroAPIDoctorGeneration $generationCase.Client 'test-model' 'unused' + Assert-Catalog ($script:mockHttp.Calls -eq 1 -and $script:mockHttp.Method -ceq 'POST') 'Doctor retried generation or did not POST.' + $request = $script:mockHttp.RequestBody | ConvertFrom-Json + Assert-Catalog ($script:mockHttp.RequestBody -notmatch 'test-neuroapi-token') 'Generation body contains credential.' + $expectedGeneration = if ($generationCase.Client -eq 'codex') { 'https://codex.neuroapi.host/v1/responses' } else { 'https://claude.neuroapi.host/v1/messages' } + Assert-Catalog ($script:mockHttp.Uri -ceq $expectedGeneration) 'Wrong generation endpoint.' + if ($generationCase.Client -eq 'codex') { Assert-Catalog ($request.max_output_tokens -eq 256 -and -not $request.store) 'Unbounded or stateful doctor request.' } + else { Assert-Catalog ($request.max_tokens -eq 64) 'Unbounded Claude doctor request.' } +} +foreach ($badGeneration in @('{}', '{"usage":{"input_tokens":10}}', '{"model":"test-model","status":"completed","output":[]}', '{"model":"unexpected-model","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}]}', '{"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}]}', '{"model":"test-model","status":"incomplete","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}]}', '{"model":"test-model","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"test-neuroapi-token"}]}]}', '{"model":"test-model","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\u0074est-neuroapi-token"}]}]}', '{"model":"test-model","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":" "}]}]}')) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Body = $badGeneration + Assert-CatalogFailure { Invoke-NeuroAPIDoctorGeneration codex 'test-model' 'unused' } + Assert-Catalog ($script:mockHttp.Calls -eq 1) 'Doctor retried failed generation.' +} +foreach ($status in @(302,401,403,429,503)) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Status = $status + $script:mockHttp.Body = 'private-provider-body test-neuroapi-token' + Assert-CatalogFailure { Invoke-NeuroAPIDoctorGeneration claude 'test-model' 'unused' } + Assert-Catalog ($script:mockHttp.Calls -eq 1) 'Doctor followed redirect or retried.' +} +foreach ($roleCase in @( + @{Client='codex';Body='{"model":"test-model","status":"completed","output":[{"type":"message","role":"user","content":[{"type":"output_text","text":"OK"}]}]}'}, + @{Client='claude';Body='{"model":"test-model","type":"message","role":"user","stop_reason":"end_turn","content":[{"type":"text","text":"OK"}]}'} +)) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Body = $roleCase.Body + Assert-CatalogFailure { Invoke-NeuroAPIDoctorGeneration $roleCase.Client 'test-model' 'unused' } +} +foreach ($identity in @($null, 'unexpected-model')) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Body = @{ model=$identity; status='completed'; output=@(@{ type='message'; role='assistant'; content=@(@{type='output_text';text='OK'})})} | ConvertTo-Json -Depth 8 -Compress + try { Invoke-NeuroAPIDoctorGeneration codex 'test-model' 'unused'; throw 'Expected identity failure' } + catch { Assert-Catalog ($_.Exception.Message -match 'model_identity_unverified') 'Identity failure was misreported as network error.' } +} # Ensure the actual factory (not the mocked handler) disables redirects/cookies. $factorySource = $originalClientFactory.ToString() Assert-Catalog ($factorySource.Contains('$handler.AllowAutoRedirect = $false')) 'Redirects are enabled.' @@ -252,6 +322,13 @@ try { } Assert-Catalog ($script:fetchCount -eq 2 -and $script:childCount -eq 2) 'Every launch must refresh.' Assert-Catalog ($script:snapshots[0] -cne $script:snapshots[1]) 'Concurrent launches share a mutable snapshot.' + $beforeDoctorChildren = $script:childCount + Invoke-NeuroAPIManagedClient codex $tempRoot @('--doctor') + Assert-Catalog ($script:childCount -eq $beforeDoctorChildren -and $script:NeuroAPIChildExitCode -eq 0) 'Doctor launched a client or failed.' + $beforeInvalidDoctor = $script:fetchCount + Assert-CatalogFailure { Invoke-NeuroAPIManagedClient codex $tempRoot @('--doctor-bad') } + Assert-CatalogFailure { Invoke-NeuroAPIManagedClient codex $tempRoot @('--doctor', '--model', 'x') } + Assert-Catalog ($script:fetchCount -eq $beforeInvalidDoctor) 'Invalid doctor flags reached credentials or network.' $script:catalogJson = '{invalid}' Assert-CatalogFailure { Invoke-NeuroAPIManagedClient codex $tempRoot @() } Assert-Catalog ($script:childCount -eq 2) 'Malformed catalog launched child.'