feat(llm): add OpenCode Go provider#782
Conversation
Wires OpenCode Go (https://opencode.ai/zen/go/v1) as a selectable LLM provider alongside the existing OpenAI-compatible providers. OpenCode Go is a paid subscription tier ($5/$10 per month) routing to ~12 open-source coding models via an OpenAI-compatible chat-completions endpoint. Changes: - Register "opencode-go" in the OpenAI-compatible factory branch and _PROVIDER_CONFIG (base URL + OPENCODE_GO_API_KEY env var). - Catalog all 12 routed models (deepseek-v4-{flash,pro}, kimi-k2.{5,6}, glm-{5,5.1}, qwen3.{5,6}-plus, minimax-m2.{5,7}, mimo-v2.5{,-pro}), partitioned into quick/deep modes by family tier. - Add to interactive CLI provider selector. - Enable streaming for opencode-go: the gateway closes connections on long non-streamed responses (~3 min idle timeout); streaming keeps the socket active. - Reuse DeepSeekChatOpenAI for opencode-go (its gateway proxies DeepSeek backends, so it inherits the same reasoning_content round-trip requirement). Add streaming-path capture via _convert_chunk_to_generation_chunk since langchain-openai's stream parser drops reasoning_content from deltas, which would otherwise cause HTTP 400 on the next turn. - Add unit tests covering provider wiring, catalog coverage for all 12 models, validator approval, and CLI registration. Verified end-to-end against opencode-go/deepseek-v4-flash with multi-turn tool-calling.
There was a problem hiding this comment.
Code Review
This pull request introduces support for the OpenCode Go LLM provider, integrating it into the CLI, model catalog, and factory. Key changes include forcing streaming for OpenCode Go to avoid connection timeouts and extending the DeepSeekChatOpenAI client to preserve reasoning_content during streaming. Review feedback points out a potential AttributeError in the streaming logic when processing non-dictionary chunks and recommends refactoring a brittle test that relies on bytecode inspection for verifying CLI registration.
| choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices") or [] | ||
| if choices: | ||
| reasoning = (choices[0].get("delta") or {}).get("reasoning_content") | ||
| if reasoning: | ||
| gen_chunk.message.additional_kwargs["reasoning_content"] = reasoning |
There was a problem hiding this comment.
The current implementation of _convert_chunk_to_generation_chunk assumes that chunk is always a dictionary by calling .get(). However, when using the standard langchain-openai and openai SDK, chunk is typically an instance of ChatCompletionChunk (a Pydantic model), which does not have a .get() method. This will cause an AttributeError during streaming.
To fix this, you should handle both dictionary and object types, similar to how it's done in _create_chat_result (line 103). Additionally, the chunk.get("chunk") check seems inconsistent with the rest of the class and might be unnecessary if the provider is truly OpenAI-compatible.
| choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices") or [] | |
| if choices: | |
| reasoning = (choices[0].get("delta") or {}).get("reasoning_content") | |
| if reasoning: | |
| gen_chunk.message.additional_kwargs["reasoning_content"] = reasoning | |
| chunk_dict = chunk if isinstance(chunk, dict) else chunk.model_dump() | |
| choices = chunk_dict.get("choices") or [] | |
| if choices: | |
| reasoning = choices[0].get("delta", {}).get("reasoning_content") | |
| if reasoning: | |
| gen_chunk.message.additional_kwargs["reasoning_content"] = reasoning |
There was a problem hiding this comment.
Pushing back on this — chunk is guaranteed to be a dict here, not a ChatCompletionChunk. langchain-openai's _stream coerces it before invoking _convert_chunk_to_generation_chunk:
# langchain_openai/chat_models/base.py, ~line 1577
for chunk in response:
if not isinstance(chunk, dict):
chunk = chunk.model_dump()
generation_chunk = self._convert_chunk_to_generation_chunk(chunk, ...)That's also why the parent method's signature is chunk: dict and why the parent itself uses chunk.get(...). The override matches the contract. End-to-end multi-turn streaming with tool calls runs without AttributeError on opencode-go/deepseek-v4-flash.
The chunk.get("chunk", {}).get("choices") fallback mirrors the parent's handling for the beta.chat.completions.stream path — keeping it lets the override stay safe if that code path is ever hit through inheritance.
| rows = [ | ||
| row | ||
| for const in select_llm_provider.__code__.co_consts | ||
| if isinstance(const, tuple) | ||
| for row in const | ||
| if isinstance(row, tuple) | ||
| and len(row) == 3 | ||
| and row[0:2] == ("OpenCode Go", "opencode-go") | ||
| ] |
There was a problem hiding this comment.
This test is extremely brittle as it relies on inspecting the Python interpreter's internal bytecode constants (__code__.co_consts) to verify the contents of a local variable inside select_llm_provider. This implementation detail is not part of the public API and can change with different Python versions or minor refactors.
A better approach would be to refactor cli/utils.py to move the PROVIDERS list to a module-level constant. This would allow you to import and verify the list directly in your tests without resorting to bytecode inspection.
There was a problem hiding this comment.
Agreed. Moved PROVIDERS to a module-level LLM_PROVIDERS constant in cli/utils.py and simplified the test to import it directly.
Test no longer depends on bytecode inspection to verify CLI registration; imports the constant directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Wires OpenCode Go (https://opencode.ai/zen/go/v1) as a selectable LLM provider alongside the existing OpenAI-compatible providers. OpenCode Go is a paid subscription tier ($5/$10 per month) routing to ~12 open-source coding models via an OpenAI-compatible chat-completions endpoint.
Changes
opencode-goin the OpenAI-compatible factory branch and_PROVIDER_CONFIG(base URL +OPENCODE_GO_API_KEYenv var).deepseek-v4-{flash,pro},kimi-k2.{5,6},glm-{5,5.1},qwen3.{5,6}-plus,minimax-m2.{5,7},mimo-v2.5{,-pro}), partitioned into quick/deep modes by family tier.opencode-go: the gateway closes connections on long non-streamed responses (~3 min idle timeout); streaming keeps the socket active.DeepSeekChatOpenAIforopencode-go(its gateway proxies DeepSeek backends, so it inherits the samereasoning_contentround-trip requirement). Add streaming-path capture via_convert_chunk_to_generation_chunksince langchain-openai's stream parser dropsreasoning_contentfrom deltas, which would otherwise cause HTTP 400 on the next turn.Verified end-to-end against
opencode-go/deepseek-v4-flashwith multi-turn tool-calling.Test Plan
pytest -m unitpasses (54 tests, including 8 new intests/test_opencode_go_provider.py)python -m cli.mainlists "OpenCode Go" in the provider menu and surfaces the 12 models under quick/deepOPENCODE_GO_API_KEYset,python main.py(withllm_provider=opencode-go, e.g.quick_think_llm=deepseek-v4-flash,deep_think_llm=deepseek-v4-pro) completes a full SPY analysis without connection errors🤖 Generated with Claude Code