# shellcheck shell=bash # linux/lib/doctor.sh - doctor, and its checks for each provider. # # Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the # CM_* paths, PY and JSON used here. Not meant to run on its own. # The preset's model ids against what the provider actually offers. A server # provider that does not answer is a failure; a hosted catalogue that cannot be # fetched is only a warning, since the endpoint may be fine regardless. doctor_catalogue() { local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3 kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)" declared="$(jget "$pf" contextTokens)" cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")" if [ -z "$cat" ]; then if [ "$(prov_field "$mode" 7)" = 1 ]; then local start; start="$(prov_field "$mode" 21)" if [ "$(prov_field "$mode" 8)" = lenient ]; then warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked" else err "$title not reachable at $base${start:+ - $start}" fi else warn "could not fetch the $title model list" fi return 0 fi [ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)" for t in "${TIERS[@]}"; do id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row='' # Ollama lists every model with its tag, and a bare name means # `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`. if [ -z "$row" ] && [ "$kind" = ollama ]; then case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac fi if [ -z "$row" ]; then # A fixed list is documentation, not the provider's word: an id # missing from it may still be served. if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")" else err "$t model NOT available from $title: $id"; fi continue fi c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)" case "$kind" in openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;; lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;; ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;; *) ok "$(printf '%-6s %s' "$t" "$id")" ;; esac done [ -n "$declared" ] && ok "declared context window: $declared tokens" \ || warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early' } doctor_ctx_vs() { local t="$1" ctx="$2" declared="$3" { [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0 if [ "$t" = haiku ]; then warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks" else warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow" fi } # Ollama sets the context window on the server, not per request from Claude # Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and # anything past it is cut off without an error. contextTokens in a preset only # tells Claude Code what to expect - it cannot change what the server does - so # say what the server is actually running where that can be seen, and what to # set where it cannot. doctor_ollama_context() { local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0 ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)" [ -n "$ids" ] || return 0 ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)" while IFS= read -r id; do [ -n "$id" ] || continue body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")" max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \ "$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)" if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens" fi live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)" [ -n "$live" ] || continue seen=1 if [ "$live" -lt "$declared" ] 2>/dev/null; then short=1 warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off" else ok "$id is loaded with a $live-token context" fi done </dev/null; then ok 'settings.json parses' else err 'settings.json does not parse'; return 1 fi if [ -x "$CM_HELPER" ]; then ok "key helper present: $CM_HELPER"; else err "key helper missing/not executable: $CM_HELPER"; fi ok "secret backend: $(cm_vault_backend_label)" if [ "$mode" != "anthropic" ]; then local am base; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault base="$(jget "$pf" baseUrl)"; base="${base%/}" if [ "$am" = "vault" ]; then local ref key out ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter if key="$(cm_vault_get "$ref" 2>/dev/null)"; then ok "vault '$ref' resolves -> $(cm_vault_mask "$key")" case "$key" in *[[:space:]]*|claude-mode*) err "the stored '$ref' value looks like a pasted command, not a key. Re-run: claude-mode set-key $ref" ;; esac else err "vault '$ref' missing. Run: claude-mode set-key $ref"; key='' fi # Check the string settings.json actually holds, then run that # string through a shell. A path this script can quote correctly is # no evidence that the recorded one parses. local stored expected reswitch stored="$(jget "$CM_SETTINGS" apiKeyHelper)" expected="$("$PY" -c 'import shlex,sys; sys.stdout.write(shlex.quote(sys.argv[1]))' "$CM_HELPER")" reswitch="claude-mode $mode $(jget "$CM_STATE" preset)" if [ -z "$stored" ]; then err "settings.json has no apiKeyHelper. Run: $reswitch" elif [ "$stored" != "$expected" ]; then err "apiKeyHelper reads $stored" err " but should read $expected - run: $reswitch" else ok "apiKeyHelper wired as $stored" fi if [ -n "$stored" ]; then out="$(sh -c "$stored" 2>&1)" if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key' elif printf '%s' "$out" | grep -q '[[:space:]]'; then # Whitespace means a diagnostic, not a credential; a key is # one unbroken token and must never be echoed. err "apiKeyHelper failed: $out" elif [ -n "$out" ]; then err "apiKeyHelper output does not match the vault (got: $(cm_vault_mask "$out"))" else err 'apiKeyHelper produced no output'; fi fi if [ -n "$key" ] && doctor_has "$mode" openrouter-key; then local kinfo kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)" if [ -n "$kinfo" ]; then ok 'OpenRouter accepted the key' printf '%s' "$kinfo" | "$PY" -c " import json,sys d=json.load(sys.stdin).get('data',{}) lim=d.get('limit'); use=d.get('usage',0) if lim is None: print(' ok spend %.2f this month (no key limit set)' % use) else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim, d.get('limit_reset','?'), d.get('limit_remaining',0))) " 2>/dev/null else err 'OpenRouter rejected the key' fi show_guardrail_status "$mode" "$key" fi # No key-info endpoint: the cheapest real check is a 1-token # message against the Anthropic-compatible surface itself. if [ -n "$key" ] && doctor_has "$mode" message-check; then if curl -fsS --max-time 45 -X POST "$base/v1/messages" \ -H 'content-type: application/json' -H "x-api-key: $key" \ -H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \ -d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then ok "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)" else err "$(prov_field "$mode" 3) request failed" fi fi else ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)" fi doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base" doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base" fi printf '\n' if check_stale_models "$mode"; then [ "$mode" != "anthropic" ] && ok 'no cached Anthropic model ids' fi printf '\n' if check_stray_env "$mode"; then ok 'no rc-file overrides'; fi write_health "$mode" "$preset" printf '\n' if command -v claude >/dev/null 2>&1; then say "claude: $(claude --version 2>&1 | head -n1) [$(command -v claude)]" else warn 'claude is not on PATH' fi }