Skip to content

fix(llm): actionable provider errors instead of a raw JSON repr (#1110) - #1126

Merged
frankbria merged 4 commits into
mainfrom
fix/1110-llm-error-messages
Aug 10, 2026
Merged

fix(llm): actionable provider errors instead of a raw JSON repr (#1110)#1126
frankbria merged 4 commits into
mainfrom
fix/1110-llm-error-messages

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #1110.

The change

A new codeframe/adapters/llm/errors.py maps a provider SDK exception to a typed
LLMError carrying text the user can act on.

The important design choice: the message lives on the exception, not in each
CLI command's error handler.
Every LLM-backed command already ends in some form
of except ... as e: print(e), so putting the mapping at the adapter boundary
covers prd generate, tasks generate, work start --execute, prd stress-test
and the server surfaces at once — and covers commands added later — instead of
requiring a per-command edit that would drift.

Status Becomes Message names
401 LLMAuthError provider, the env var its key is read from, cf env check
404 LLMModelNotFoundError (new) the model that failed, the CODEFRAME_<PURPOSE>_MODEL override
429 LLMRateLimitError rate limited, wait and retry
5xx / 529 LLMOverloadedError (new) provider-side, not your configuration

The env var is resolved per provider, so an openai user is never told to
check ANTHROPIC_API_KEY.

Adapter wiring — including the one that caused the report

  • Anthropic sync complete() had no error mapping at all. That is the path
    behind the issue: the SDK exception escaped verbatim.
  • Anthropic async and both OpenAI paths produced typed errors but stringified
    the raw SDK body into them; OpenAI sync raised a bare ValueError.
  • Status is read from the exception, falling back to its type name, so a
    partially-constructed error (or a stubbed one) still classifies correctly.

core/tasks.py now also lets LLMError escape the task-generation fallback, for
the same reason ValueError already did — degrading a bad key into bullet
extraction hides the one thing the user needs to read. (This is order-independent
with #1115, which removes that fallback entirely.)

Evidence — live against the real API

Bad key, via cf prd stress-test:

Error: PRD stress test failed while calling the LLM provider: The anthropic API
rejected the API key.
  Key read from: $ANTHROPIC_API_KEY
  Provider: anthropic (set CODEFRAME_LLM_PROVIDER or llm.provider in .codeframe/config.yaml)

Check that $ANTHROPIC_API_KEY is set to a current key, then re-run.
`cf env check` verifies your setup.

(set CODEFRAME_VERBOSE=1 to see the raw provider response)

The retired model from the report (CODEFRAME_PLANNING_MODEL=claude-3-5-haiku-20241022):

Error: PRD stress test failed while calling the LLM provider: The anthropic API
does not recognise the model 'claude-3-5-haiku-20241022'.

  Override it with: $CODEFRAME_PLANNING_MODEL
  or set llm.model in .codeframe/config.yaml

This usually means the model was retired. `cf env check` verifies your setup.

Same command with CODEFRAME_VERBOSE=1 — the original payload, unmodified:

Provider response:
Error code: 404 - {'type': 'error', 'error': {'type': 'not_found_error',
'message': 'model: claude-3-5-haiku-20241022'}, 'request_id': 'req_011CduJbYqyRj4wcVwT8QfkZ'}

19 new tests; tests/adapters/ 204 passed.

Acceptance criteria

  • 401 names the resolved provider, the env var read, and a next step
  • 404 names the configured model and the CODEFRAME_*_MODEL override
  • 429 and 529 get the same treatment
  • Raw payload still available — __cause__ always, CODEFRAME_VERBOSE=1 to print
  • Applies to every LLM-backed command, via the adapter boundary
  • Tests assert the mapped message for 401 and 404

Judgment calls

  • CODEFRAME_VERBOSE=1 rather than a --verbose flag. The AC says
    "--verbose / in the event log". prd generate and tasks generate have no
    --verbose today, so a flag would mean adding and threading one through each
    command — a larger, driftier diff for the same result. An env var works
    everywhere immediately, including the server. Say the word if you'd rather have
    the flags and I'll add them on top.
  • LLMError does not inherit ValueError. OpenAI sync previously raised
    ValueError, so this is a contract change; two tests asserted the old shape and
    are updated. I checked the except ValueError sites — the only one in a
    provider path is the task-generation fallback, handled above.

Known limitations

  • Streaming paths (async_stream) still use their original narrower handlers.
    They are not on the first-run path this issue is about; folding them in is
    mechanical follow-up if wanted.
  • The 401 branch also matches on "authentication" in the message text as a
    backstop for providers that report auth failures without a status.

The two most likely first-run failures both reached the user as a Python repr
of the provider's JSON body, at the first AI-backed command in the README
quickstart:

    Error: Error code: 401 - {'type': 'error', 'error': {'type':
    'authentication_error', 'message': 'API key is invalid.'}, 'request_id': None}

It names neither the env var to fix nor a next step, and the 404 form exposes an
internal model constant the user never chose.

New codeframe/adapters/llm/errors.py maps a provider SDK exception to a typed
LLMError with text the user can act on. It lives on the exception rather than in
each command's error handler, so it reaches every LLM-backed surface — prd
generate, tasks generate, work start --execute, prd stress-test, the server —
through the `except ... as e: print(e)` each already has. One place, whole
surface, no per-command plumbing.

  401 -> names the provider, the env var the key is read from (per provider, so
         an openai user is not told about ANTHROPIC_API_KEY), and `cf env check`
  404 -> names the model that failed and the CODEFRAME_<PURPOSE>_MODEL override
         that controls it. New LLMModelNotFoundError: a retired model ID is the
         failure that made 0.9.1 unusable and deserves its own type.
  429 -> rate limited, wait and retry
  5xx/529 -> new LLMOverloadedError: the provider's side, not your config

The raw payload is never lost: `raise ... from exc` keeps it on __cause__, and
CODEFRAME_VERBOSE=1 prints it. The default message advertises that switch.

Adapter wiring:
- Anthropic sync complete() had NO mapping at all — that is the code path
  behind the report, letting the SDK exception escape verbatim.
- Anthropic async and both OpenAI paths produced typed errors but stringified
  the raw SDK body into them. OpenAI sync raised bare ValueError.
- Status is read from the exception, falling back to the exception's type name,
  so a partially-constructed error still classifies correctly.

core/tasks.py also lets LLMError escape the task-generation fallback, for the
same reason ValueError already did: degrading a bad key into bullet extraction
hides the one thing the user needs to read.

Two openai tests asserted the old bare-ValueError contract and are updated.

Verified live against the real API on both paths, including CODEFRAME_VERBOSE=1
reproducing the original payload.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 7m 2s —— View job


Precision bug-hunting review — complete

Reviewed PR #1126 for concrete defects only (logic errors, crashes, security, data loss, breaking changes). Style/architecture/coverage are covered by a separate review.

  • Gather PR diff and existing comments (none to duplicate)
  • Read each changed file in surrounding context
  • Trace callers/callees — Purpose enum → env-var mapping correct; kwargs["model"]/purpose always in scope; no narrow-handler escapes for the two new subclasses; react-agent execution path surfaces errors (no silent regression)
  • Confirm the two except ValueError sites near LLM code are not provider paths
  • Post inline finding (1 minor)
  • Post summary comment

Result: 1 finding (minor), no critical/major issues.

  • minor codeframe/adapters/llm/errors.py:84CODEFRAME_VERBOSE uses a bare truthy check, so =0/=false/=no/=off still enable raw-payload output, diverging from the repo's boolean-env convention.

Summary comment and inline finding posted.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review

This is a solid fix — the design (mapping at the adapter boundary rather than per-command) is the right call and matches the "one place, whole surface" reasoning in the PR description. Tests are thorough and the live-API evidence is convincing. A few findings:

1. provider="openai" is hardcoded even for ollama/vllm/compatible (moderate)

codeframe/adapters/llm/__init__.py routes "openai", "ollama", "vllm", and "compatible" provider types all through the same OpenAIProvider class (OPENAI_COMPATIBLE_PROVIDERS). But OpenAIProvider.complete()/async_complete() (codeframe/adapters/llm/openai.py:158, :207) always pass provider="openai" to map_provider_error. So a user running CODEFRAME_LLM_PROVIDER=ollama who hits a failure gets:

The openai API rejected the API key.
  Key read from: $OPENAI_API_KEY
  Provider: openai (set CODEFRAME_LLM_PROVIDER or llm.provider in .codeframe/config.yaml)

That's actively wrong for a local model — get_provider() deliberately sets api_key="not-required" for non-openai local providers when OPENAI_API_KEY is unset (codeframe/adapters/llm/__init__.py:80-83), so the suggested fix doesn't even apply. This undercuts the PR's own stated goal ("an openai user is never told to check ANTHROPIC_API_KEY") for the three other provider types that share this adapter. OpenAIProvider would need to know/store which of the four it actually is (e.g. from get_provider()'s provider_type) to label this correctly.

2. 403 (PermissionDeniedError) is tracked but never handled (minor)

_TYPE_TO_STATUS maps "PermissionDeniedError": 403, but map_provider_error's if-chain has no status == 403 branch, so it falls through to the generic, non-actionable LLMConnectionError("The {provider} API call failed.") — the least helpful message of the bunch, despite the type name being explicitly recognized. Either add a branch (e.g. "key is valid but lacks access to this model/feature") or the mapping entry is dead weight.

3. The 401 substring backstop can override a known non-401 status (minor)

if status == 401 or "authentication" in str(exc).lower():

This is OR'd unconditionally, so if _status_of resolves a known non-401 status (e.g. 403) but the message text happens to contain "authentication" (plausible phrasing for a permission error), it gets misclassified as LLMAuthError anyway. The substring fallback reads like it's meant for the case where status is unknown — consider gating it on status is None.

4. _with_raw verbose check doesn't match the codebase's flag convention (nit)

if os.getenv(VERBOSE_ENV):

This is truthy for any non-empty string, so CODEFRAME_VERBOSE=0 or =false would turn verbose mode on. Every other boolean env flag in this codebase (_env_flag in ui/server.py, and the same pattern in hook_trust.py, notifications_config.py, agent_env.py, engine_registry.py, cli/app.py) parses as .strip().lower() in {"1","true","yes","on"}. Worth matching for consistency, though low stakes since it only affects whether extra debug text is shown.

Minor style

  • errors.py:108 ( Provider: {provider} (set CODEFRAME_LLM_PROVIDER ...)) runs past the project's 100-char line length (won't fail ruff check . since E501 isn't in the selected rule set here, but worth wrapping for consistency with the rest of the file).

What's good

  • raise ... from exc is used consistently, so __cause__ always carries the original SDK exception — verified by test.
  • Status detection falls back to exception type name when status_code/response aren't present, which is a nice defensive touch for partially-constructed/stubbed exceptions.
  • core/tasks.py's new except LLMError: raise is correctly ordered before the generic except Exception fallback, and the reasoning mirrors the existing ValueError case.
  • Per-provider env var resolution (for the two directly-owned providers, anthropic/openai) is a genuine improvement over the status quo.
  • Good test coverage of the acceptance criteria, including the CODEFRAME_VERBOSE on/off behavior and cause-preservation.

🤖 Generated with Claude Code

Comment thread codeframe/adapters/llm/errors.py Outdated
@github-actions

Copy link
Copy Markdown
Contributor

GLM precision review

Scoped to concrete defects only (logic errors, crashes, security, data loss, breaking changes). Style, architecture, naming, and coverage are covered by a separate review.

Severity File:line Finding
minor codeframe/adapters/llm/errors.py:84 CODEFRAME_VERBOSE uses a bare truthy check, so =0/=false/=no/=off still enable the raw-payload output — diverges from the repo's boolean-env convention (env_provenance.py:122, hook_trust.py:104, notifications_config.py:63) where those values mean off.

Notable things checked and cleared:

  • Purpose enum values (planning/execution/generation/correction/supervision) → _model_override_env produces real CODEFRAME_*_MODEL names that match ModelSelector.__post_init__.
  • kwargs["model"] and purpose are always in scope at all four adapter call sites (anthropic.{complete,async_complete}, openai.{complete,async_complete}).
  • _status_of correctly classifies the test fixtures (real openai.AuthenticationError("…", response=MagicMock(), body={}) has a non-int status_code, so the MRO type-name fallback is what returns 401 — and that fallback is the documented reason the check exists).
  • No production caller catches the old narrow LLMAuthError/LLMRateLimitError/LLMConnectionError trio, so the two new subclasses (LLMModelNotFoundError, LLMOverloadedError) can't escape a narrow handler. core/tasks.py catches LLMError (base), and the react-agent execution path funnels provider errors through a broad handler that emits AGENT_FAILED — no silent degradation or retry-loop regression from sync errors now being typed.
  • The two except ValueError sites near LLM code (react_agent.py:1052, prd_stress_test.py:268) are path-traversal and enum-parse, not provider-call paths — the only provider-path one is the tasks.py fallback, which the PR correctly updates.
  • core/tasks.py importing codeframe.adapters.llm.base adds no import cycle (base.py imports nothing from core).

1 finding (1 minor). No critical/major issues.

…convention (#1110)

Three review findings, all real.

1. OpenAIProvider serves openai, ollama, vllm and compatible, but hardcoded
   provider="openai" in its error mapping. So an ollama user was told to check
   $OPENAI_API_KEY — which local providers do not use at all; get_provider
   constructs them with api_key="not-required". This undercut the whole point
   of the change for three of the four types it covers. The provider now knows
   which one it is, and the 401 branch gives local providers endpoint advice
   instead of key advice.

2. 403 was recognised in _TYPE_TO_STATUS but had no branch, so PermissionDenied
   fell through to the least useful message in the file. It now says the key is
   valid but lacks access to the model.

3. CODEFRAME_VERBOSE used a bare truthy check, so =0/false/no/off still printed
   the raw payload. Now uses the same truthy set as env_provenance, hook_trust
   and notifications_config.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 34s —— View job


GLM precision review (re-trigger on synchronize)

Scoped to concrete defects only (logic errors, crashes, security, data loss, breaking changes). Style/architecture/coverage covered by the separate general review.

The synchronize brought commit 07fb1ef which addresses three prior findings (provider labelling, 403 handling, boolean-env convention). Re-scanned the new code for correctness.

  • Gather full PR diff + existing comments (avoid duplicating findings)
  • Read each changed file in surrounding context (errors.py, openai.py, __init__.py, new tests)
  • Verify the 3 claimed fixes are correct and introduce no new defects
  • Trace callers/callees — kwargs["model"]/purpose always in scope at both call sites; no production code branches on the narrow subclasses (only the adapter streaming path + the mapper use them), so 403→LLMAuthError is message-only; provider_name wiring verified end-to-end via get_provider
  • Post confirmed inline finding + summary

Result: 1 new finding (minor). No critical/major.

Severity File:line Finding
minor codeframe/adapters/llm/errors.py:118 "needs no API key" 401 advice fires for provider="compatible" even when OPENAI_API_KEY is set and being sent — asserts a falsehood and never names OPENAI_API_KEY; regression vs bc83ac9 for the remote OpenAI-compatible-gateway use case. Inline suggestion posted.

Things checked and cleared in the new commit:

  • provider_name=provider_type round-trips correctly for all four compatible types (get_providerOpenAIProvidermap_provider_error); the new test test_get_provider_labels_each_compatible_type_correctly pins it.
  • 403 branch (errors.py:143) returns LLMConnectionErrorLLMAuthError; grep confirms no except/isinstance branches on these subclasses outside the adapter layer, so no retry-loop or behavioral regression — message-only change.
  • _verbose() now uses the repo's {1,true,yes,on} truthy set; new parametrized test covers off-values (0/false/no/off/"") and on-values incl. TRUE.
  • _status_of still classifies the test fixtures correctly (real httpx.Response carries an int status_code).

Not re-posted (already on the PR record): the ungated "authentication" in str(exc) substring backstop at errors.py:114 (general-review finding #3) is still unaddressed and now also shadows the new 403 branch for any 403 whose body mentions "authentication". Left as-is here to avoid a duplicate finding.

@frankbria

Copy link
Copy Markdown
Owner Author

All three findings were real and are fixed in the latest commit.

1. provider="openai" hardcoded for ollama/vllm/compatible (moderate) — correct, and it undercut the change's own premise for three of the four types sharing that adapter. OpenAIProvider now takes a provider_name, get_provider passes the real provider_type through, and the 401 branch gives local providers endpoint advice instead of key advice — since, as you noted, they run with api_key="not-required" and have no key to check.

2. 403 recognised but unhandled (minor) — correct, it fell through to the least useful message in the file. It now has its own branch naming the model and the account whose key was used.

3. CODEFRAME_VERBOSE bare truthy check (minor) — correct. Now uses the same truthy set as env_provenance/hook_trust/notifications_config, so 0/false/no/off mean off.

Six new tests cover all three, including one asserting an ollama failure never mentions OPENAI_API_KEY. tests/adapters/: 217 passed.

Thanks — #1 in particular was the kind of thing that would have read as a fix while still being wrong for local-model users.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review

Re-reviewed after the follow-up commit (07fb1ef). This addresses three of the four findings from my earlier review and the GLM/Claude-workflow reviews on this PR:

  1. Provider hardcoding (moderate, from my earlier review) — fixed. OpenAIProvider now takes provider_name (codeframe/adapters/llm/openai.py:51,71), get_provider() passes provider_type through (codeframe/adapters/llm/__init__.py:95), and errors.py's _LOCAL_PROVIDERS set gives ollama/vllm/compatible endpoint-focused advice instead of an $OPENAI_API_KEY suggestion that never applied to them. Confirmed via test_a_local_provider_is_not_told_to_check_openai_api_key and test_get_provider_labels_each_compatible_type_correctly.
  2. 403 falling through to the generic message (minor) — fixed. errors.py:143-154 now has a dedicated branch ("key is valid but lacks access"), covered by test_403_gets_its_own_message_not_the_generic_one.
  3. CODEFRAME_VERBOSE bare-truthy check (minor/nit, also flagged independently by the GLM review) — fixed. errors.py:36-40 now uses the same _TRUTHY set as env_provenance/hook_trust/notifications_config, covered by test_verbose_off_values_mean_off / test_verbose_on_values_mean_on.

Still open — not addressed by the follow-up commit:

  • The 401 substring backstop can still misclassify a 403 (codeframe/adapters/llm/errors.py:114):
    if status == 401 or "authentication" in str(exc).lower():
    This check runs before the new status == 403 branch, and the OR is unconditional. A PermissionDeniedError (status 403) whose message text happens to contain "authentication" (e.g. "authentication succeeded but you lack access to this model" — plausible provider phrasing) still gets routed to LLMAuthError's 401 message, telling the user their key is invalid and to re-check $ANTHROPIC_API_KEY/$OPENAI_API_KEY, even though the key is fine and the new, more accurate 403 message (added in this same commit) never runs. No test exercises this combination — test_403_gets_its_own_message_not_the_generic_one uses a body of just "boom", which doesn't contain "authentication". Gating the substring fallback on status is None (its apparent intent, per the earlier review thread) would fix this without weakening the case it's actually meant to catch — an exception with no resolvable status at all.

Everything else from earlier passes still holds: the adapter-boundary design is correct, raise ... from exc consistently preserves __cause__, core/tasks.py's new except LLMError: raise is correctly ordered ahead of the generic fallback, and test coverage of the acceptance criteria (401/404/429/5xx, per-provider env resolution, verbose toggle) is solid.

🤖 Generated with Claude Code

Comment thread codeframe/adapters/llm/errors.py Outdated
…ssages

# Conflicts:
#	codeframe/core/tasks.py
@frankbria

Copy link
Copy Markdown
Owner Author

Merged main in and resolved the conflict this PR flagged.

#1115 (now on main) deletes the task-generation fallback entirely, so the except LLMError: raise guard added here has nothing left to escape from — provider errors propagate on their own. Took main's side of the block and dropped the now-unused LLMError import from core/tasks.py.

Both features verified together: 333 passed across tests/adapters/, test_task_decomposition_1115.py, test_discovery_generate_tasks.py and test_v2_cli_integration.py.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 5m 8s —— View job


GLM precision review (re-trigger on synchronize)

Scoped to concrete defects only (logic errors, crashes, security, data loss, breaking changes). Style/architecture/coverage covered by the separate general review.

The synchronize (b987904) is a merge of main; it left codeframe/adapters/llm/** byte-identical to 07fb1ef (verified — the only conflict resolution was core/tasks.py, taking main's side per #1115). So this re-review covers the same adapter code as the prior round, plus confirms the merge introduced nothing new in this area.

  • Gather full PR diff + existing comments (avoid duplicating findings)
  • Read each changed file in surrounding context (errors.py, openai.py, anthropic.py, __init__.py, base.py, both test files)
  • Trace callers/callees — kwargs["model"]/model and purpose in scope at all four call sites; the only production OpenAIProvider( construction is via get_provider (passes provider_name=provider_type); no caller catches a specific non-LLMError exception (timeout/connection) from complete/async_complete that the broader except Exception would now swallow
  • No new inline findings to post
  • Summary

Result: ✅ no new defects in this synchronize.

Verified clean on this pass:

  • _status_of resolves correctly for the real-SDK test fixtures (openai.AuthenticationError("…", response=MagicMock(), body={}) has a non-int status_code, so the MRO type-name fallback returns 401 — the documented reason it exists).
  • provider_name=provider_type round-trips for all four compatible types (get_providerOpenAIProvidermap_provider_error); no production site constructs OpenAIProvider(...) directly with the wrong default.
  • All four raise sites use raise ... from exc, so __cause__ preserves the original payload (asserted by test_the_original_exception_is_preserved_as_the_cause).
  • The core/tasks.py except LLMError guard added earlier was correctly dropped by the merge ([P0.32] cf tasks generate emits PRD bullets verbatim as tasks — persona traits and raw markdown, no dependencies #1115 removed that fallback), so no dangling import or dead handler remains — confirmed absent from the diff.

Carried forward — already on the PR record from earlier commits, not re-posted (consistent with the convention the prior GLM round set for these two):

  1. codeframe/adapters/llm/errors.py:114 — the "authentication" in str(exc).lower() substring backstop is OR'd unconditionally with status == 401, so it can mis-route a known 403 (or any status) whose body mentions "authentication" into the 401 branch, shadowing the new 403 branch added in this same PR. (In the claude summary of 15:47 and the GLM round of 15:44; no inline comment exists, but it is on the record.) Gating on status is None would fix it without weakening the no-status case it is meant for.
  2. codeframe/adapters/llm/errors.py:118-123 — the "runs locally and needs no API key" advice fires for provider="compatible" even when OPENAI_API_KEY is set and being sent. (Inline comment of 15:49, with a suggestion block.)

Neither was touched by the merge.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review

Re-reviewed at the current head (b987904, merged into main via the synthetic merge commit). This is a well-scoped, well-tested fix, and the two follow-up commits genuinely addressed the three findings from earlier review rounds (provider mislabeling for ollama/vllm/compatible, the missing 403 branch, and the CODEFRAME_VERBOSE truthy check). The merge with main (dropping the now-dead except LLMError guard in core/tasks.py after #1115 removed the fallback it protected) is clean — no leftover unused import, confirmed by reading the current file.

One thing not yet raised in the four prior review passes on this PR:

async_complete swallows response-parsing bugs and mislabels them as provider failures (moderate, untested)

In both codeframe/adapters/llm/anthropic.py:187-195 and codeframe/adapters/llm/openai.py:208-217, _parse_response(response) runs inside the try/except Exception as exc: raise map_provider_error(...) block. The sync complete() path in both files keeps _parse_response outside the try (anthropic.py:140-148, openai.py:160-170), so this is a new inconsistency between the two paths, not a pre-existing pattern.

Before this PR, the async paths already wrapped _parse_response in try, but the except clauses were narrow (AuthenticationError, RateLimitError, APIConnectionError), so a bug in parsing — e.g. an AttributeError — wouldn't match and would propagate as itself, clearly identifiable as a code bug. This PR broadens that to bare except Exception, so a parsing bug now gets caught, passed to map_provider_error, falls through every status-based branch (no status_code/response attributes, no recognized type name → _status_of returns None), and comes out as LLMConnectionError("The {provider} API call failed.") — actively misleading, since the API call succeeded and the bug is local.

This isn't hypothetical: openai.py:510 has response.usage.prompt_tokens, and the SDK response's usage field can be None — the streaming path in the same file already defends against exactly this (openai.py:280: if chunk.usage is not None). An OpenAI-compatible gateway/local model that omits usage on a non-streaming response (or an empty choices list, e.g. content-filtered responses → IndexError at openai.py:473) would now surface as a generic "API call failed, re-run" message pointing away from the actual bug, exactly the class of confusing error text this PR is trying to eliminate elsewhere.

None of the 19 new tests exercise async_complete's error-mapping path at all (the TestTheAdaptersUseIt class docstring claims to cover "both the sync and async entry points of both providers," but only sync tests exist), so this gap wouldn't be caught by the added coverage.

Suggested fix: move return self._parse_response(response) outside the try block in both async_complete methods, matching the sync paths.

Still open from earlier rounds (not new — noting for the record since it wasn't addressed in the latest commit)

errors.py:114if status == 401 or "authentication" in str(exc).lower(): still runs unconditionally before the status == 403 branch added in the second commit. A 403 (PermissionDeniedError) whose message text happens to contain "authentication" still gets misclassified as a 401 and never reaches the now-correct 403 message. Gating the substring fallback on status is None was suggested twice already and remains a one-line fix.

What's solid

  • Adapter-boundary design, per-provider env resolution, raise ... from exc cause-preservation, and the provider_name plumbing through get_provider all check out.
  • core/tasks.py merge conflict resolution is correct and leaves no dead import.
  • Live-API evidence in the PR description is convincing for the paths it covers (sync).

🤖 Generated with Claude Code

#1110)

Review finding against my own previous fix. get_provider hands OPENAI_API_KEY to
ollama/vllm/compatible whenever it is set, and only substitutes "not-required"
when it is not. So "This provider runs locally and needs no API key" was false
in exactly the case that matters: CODEFRAME_LLM_PROVIDER=compatible against a
gateway, with a wrong key set — the user is told the key is irrelevant while the
key is the thing being rejected, and the message never names OPENAI_API_KEY.

The advice now branches on whether a key was actually sent, so a key-present
failure names the variable and a key-absent one points at the endpoint.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 36s —— View job


GLM precision review (re-trigger on synchronize)

Scoped to concrete defects only (logic errors, crashes, security, data loss, breaking changes). Style/architecture/coverage covered by the separate general review.

The synchronize brought commit 78d8437 ("local-provider key advice must follow what was actually sent"), which addresses the carried-forward finding #2 (the inline comment of 15:49 — compatible being told "needs no API key" while its key was being rejected). Re-scanned the new code for correctness.

  • Gather full PR diff + existing comments (avoid duplicating findings)
  • Read each changed file in surrounding context (errors.py, __init__.py, llm_resolution.py, both test files)
  • Verify the fix is correct and introduces no new defects
  • Trace callers/callees — get_provider key decision vs errors.py os.getenv check; all OpenAIProvider( construction sites
  • No new inline findings to post
  • Summary

Result: ✅ GLM review: no new defects in this synchronize.

The new errors.py:124 branch (if os.getenv("OPENAI_API_KEY")) decides "was a key actually sent?" for local providers. This is the load-bearing check, so I traced whether it can diverge from get_provider's real decision:

  • get_provider (__init__.py:79-83) computes api_key = kwargs.get("api_key") or os.environ.get("OPENAI_API_KEY"), then substitutes "not-required" only when that is falsy and the type isn't openai. So a real key is sent iff OPENAI_API_KEY is set (or an api_key kwarg is passed).
  • The only production construction of OpenAIProvider is get_provider itself (__init__.py:84); every other OpenAIProvider( site is test code (grep-confirmed). And the production resolution path create_provider → LLMSettings.provider_kwargs() (llm_resolution.py:60-67) forwards only model and base_urlnever api_key. So the kwarg is always None in production, and get_provider's "was a real key sent?" decision reduces exactly to os.environ.get("OPENAI_API_KEY") — which is what errors.py:124 tests.
  • Truth-table agreement holds on the edge case too: OPENAI_API_KEY=""os.getenv falsy (else-branch, "sent no API key") and get_provider also treats "" as falsy → "not-required". No divergence.

So the message can no longer assert "was sent no API key" / "needs no API key" in the one case that mattered (compatible against a gateway with a wrong key set); it now names $OPENAI_API_KEY. The two new tests (test_a_compatible_provider_with_a_key_set_is_told_about_that_key, test_a_local_provider_with_no_key_gets_endpoint_advice) and the delenv added to test_a_local_provider_is_not_told_to_check_openai_api_key (necessary now that the branch is env-dependent, where it wasn't before) pin both sides correctly.

Carried forward — already on the PR record from earlier commits, not re-posted (consistent with the convention the prior two GLM rounds set):

  1. codeframe/adapters/llm/errors.py:114if status == 401 or "authentication" in str(exc).lower(): is still OR'd unconditionally, so a known 403 (PermissionDeniedError) whose body mentions "authentication" is misrouted to the 401 branch and never reaches the dedicated 403 message added in 07fb1ef. Not touched by this commit. Gating the substring fallback on status is None (its apparent intent) remains the one-line fix. (On the record since the 15:44 / 15:47 rounds.)

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review

This is at the current head (78d8437, PR description confirms this is the tip). The overall design — mapping SDK exceptions to actionable LLMErrors at the adapter boundary rather than per-command — is the right call, and the four commits show a healthy loop of catching real problems in review (provider mislabeling for ollama/vllm/compatible, missing 403 branch, CODEFRAME_VERBOSE truthy check, and then a self-caught follow-up on the local-provider key advice). I re-verified all of those against the current code and they're genuinely fixed. Two things from the review thread remain open at this head, plus one new minor nit:

Still open (raised in earlier rounds on this PR, not yet addressed)

1. errors.py:114 — the "authentication" in str(exc).lower() substring backstop can still shadow the 403 branch.

if status == 401 or "authentication" in str(exc).lower():

This still runs unconditionally ahead of status == 403 (added in the second commit). A PermissionDeniedError (403) whose message text happens to mention "authentication" (plausible provider phrasing, e.g. "authentication succeeded but access denied") gets misclassified as LLMAuthError's 401 message — telling the user their key is invalid and to check $ANTHROPIC_API_KEY/$OPENAI_API_KEY — and never reaches the more accurate 403 message this same PR added. Gating the substring fallback on status is None (its apparent intent — a catch-all for when no status can be resolved at all) would fix this without weakening the case it's meant for. No test in TestReviewFindings::test_403_gets_its_own_message_not_the_generic_one exercises this combination (its body is just "boom", no "authentication" substring).

2. async_complete now wraps response parsing inside the error-mapping try, unlike the sync path (moderate).
In both anthropic.py:187-195 and openai.py:208-217, _parse_response(response) runs inside the try/except Exception as exc: raise map_provider_error(...) block. The sync complete() in both files keeps _parse_response outside the try (anthropic.py:140-148, openai.py:160-170), so this is a new inconsistency introduced by this PR, not pre-existing. Before this PR the async paths had narrow excepts (AuthenticationError, RateLimitError, APIConnectionError), so a parsing bug (e.g. AttributeError) would propagate as itself — clearly a code bug. Broadening to bare except Exception means a parsing bug (e.g. response.usage being None on a gateway that omits it on non-streaming responses — the streaming path in openai.py:280 already guards if chunk.usage is not None, non-streaming doesn't — or an empty choices list causing an IndexError) now gets caught, falls through every status branch in map_provider_error (no status_code, no recognized type name), and surfaces as LLMConnectionError("The {provider} API call failed.") — actively misleading, since the call succeeded and the bug is local. None of the 19 new tests exercise async_complete's error-mapping path, so this gap isn't covered. Suggested fix: move return self._parse_response(response) outside the try in both async methods, matching sync.

New minor nit

tests/adapters/test_llm_error_messages_1110.py:261-277test_a_local_provider_is_not_told_to_check_openai_api_key got a monkeypatch.delenv(...) inserted above its docstring in the last commit, so the triple-quoted string is now a dead statement rather than an actual docstring (harmless, but worth moving back above the monkeypatch call). Separately, this test now duplicates test_a_local_provider_with_no_key_gets_endpoint_advice immediately above it — both delete OPENAI_API_KEY and assert the same ollama/no-key shape (base_url present, OPENAI_API_KEY absent). Not a bug, just redundant coverage from the merge of two review-fix rounds.

What's solid (confirmed at this head)

  • Provider labelling (provider_name threaded through get_providerOpenAIProvider) is correct for all four compatible types, including the follow-up fix for a compatible gateway with OPENAI_API_KEY set.
  • 403 has its own message now and no longer falls through to the generic one.
  • CODEFRAME_VERBOSE uses the repo's shared truthy-set convention.
  • raise ... from exc is consistent everywhere, so __cause__ always carries the original SDK exception.
  • The main merge cleanly dropped the now-unused except LLMError guard in core/tasks.py after [P0.32] cf tasks generate emits PRD bullets verbatim as tasks — persona traits and raw markdown, no dependencies #1115 removed the fallback it protected — no dangling import, verified by reading the current file.
  • Test coverage of the acceptance criteria (401/404/429/5xx, per-provider env resolution, verbose toggle) is thorough for the paths it covers.

Neither open item is a blocker on its own — #1 has a narrow, plausible trigger condition, and #2 only bites on a malformed/nonstandard provider response — but both are one-line-ish fixes that would close out the last gaps this thread has already surfaced.

@frankbria
frankbria merged commit 28f88c6 into main Aug 10, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1.39] LLM provider errors surface as raw JSON dicts on the first-run path

1 participant