fix: resolve Codex provider bugs and simplify implementation
This commit is contained in:
parent
9e5f7348fe
commit
1ce586e9f5
31
README.md
31
README.md
@ -585,6 +585,37 @@ Config file: `~/.nanobot/config.json`
|
|||||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||||
|
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||||
|
|
||||||
|
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
|
||||||
|
|
||||||
|
**1. Login:**
|
||||||
|
```bash
|
||||||
|
nanobot provider login openai-codex
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openai-codex/gpt-5.1-codex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Chat:**
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Docker users: use `docker run -it` for interactive OAuth login.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
||||||
|
|||||||
@ -290,10 +290,7 @@ def _make_provider(config: Config):
|
|||||||
|
|
||||||
# OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation.
|
# OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation.
|
||||||
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
|
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
|
||||||
return OpenAICodexProvider(
|
return OpenAICodexProvider(default_model=model)
|
||||||
default_model=model,
|
|
||||||
api_base=p.api_base if p else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not model.startswith("bedrock/") and not (p and p.api_key):
|
if not model.startswith("bedrock/") and not (p and p.api_key):
|
||||||
console.print("[red]Error: No API key configured.[/red]")
|
console.print("[red]Error: No API key configured.[/red]")
|
||||||
|
|||||||
@ -192,7 +192,7 @@ class ProvidersConfig(BaseModel):
|
|||||||
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) # AiHubMix API gateway
|
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
|
||||||
|
|
||||||
|
|
||||||
class GatewayConfig(BaseModel):
|
class GatewayConfig(BaseModel):
|
||||||
@ -252,19 +252,19 @@ class Config(BaseSettings):
|
|||||||
model_lower = (model or self.agents.defaults.model).lower()
|
model_lower = (model or self.agents.defaults.model).lower()
|
||||||
|
|
||||||
# Match by keyword (order follows PROVIDERS registry)
|
# Match by keyword (order follows PROVIDERS registry)
|
||||||
# Note: OAuth providers don't require api_key, so we check is_oauth flag
|
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(self.providers, spec.name, None)
|
p = getattr(self.providers, spec.name, None)
|
||||||
if p and any(kw in model_lower for kw in spec.keywords):
|
if p and any(kw in model_lower for kw in spec.keywords):
|
||||||
# OAuth providers don't need api_key
|
|
||||||
if spec.is_oauth or p.api_key:
|
if spec.is_oauth or p.api_key:
|
||||||
return p, spec.name
|
return p, spec.name
|
||||||
|
|
||||||
# Fallback: gateways first, then others (follows registry order)
|
# Fallback: gateways first, then others (follows registry order)
|
||||||
# OAuth providers are also valid fallbacks
|
# OAuth providers are NOT valid fallbacks — they require explicit model selection
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
|
if spec.is_oauth:
|
||||||
|
continue
|
||||||
p = getattr(self.providers, spec.name, None)
|
p = getattr(self.providers, spec.name, None)
|
||||||
if p and (spec.is_oauth or p.api_key):
|
if p and p.api_key:
|
||||||
return p, spec.name
|
return p, spec.name
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import json
|
|||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from oauth_cli_kit import get_token as get_codex_token
|
from oauth_cli_kit import get_token as get_codex_token
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
@ -59,9 +60,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
try:
|
try:
|
||||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
|
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Certificate verification failed, downgrade to disable verification (security risk)
|
|
||||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||||
raise
|
raise
|
||||||
|
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
|
||||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
|
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
@ -77,6 +78,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return self.default_model
|
return self.default_model
|
||||||
|
|
||||||
|
|
||||||
def _strip_model_prefix(model: str) -> str:
|
def _strip_model_prefix(model: str) -> str:
|
||||||
if model.startswith("openai-codex/"):
|
if model.startswith("openai-codex/"):
|
||||||
return model.split("/", 1)[1]
|
return model.split("/", 1)[1]
|
||||||
@ -94,6 +96,7 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
|
|||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _request_codex(
|
async def _request_codex(
|
||||||
url: str,
|
url: str,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
@ -107,36 +110,25 @@ async def _request_codex(
|
|||||||
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
|
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
|
||||||
return await _consume_sse(response)
|
return await _consume_sse(response)
|
||||||
|
|
||||||
|
|
||||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
# Nanobot tool definitions already use the OpenAI function schema.
|
"""Convert OpenAI function-calling schema to Codex flat format."""
|
||||||
converted: list[dict[str, Any]] = []
|
converted: list[dict[str, Any]] = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
fn = tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None
|
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
|
||||||
if fn and isinstance(fn, dict):
|
name = fn.get("name")
|
||||||
name = fn.get("name")
|
if not name:
|
||||||
desc = fn.get("description")
|
|
||||||
params = fn.get("parameters")
|
|
||||||
else:
|
|
||||||
name = tool.get("name")
|
|
||||||
desc = tool.get("description")
|
|
||||||
params = tool.get("parameters")
|
|
||||||
if not isinstance(name, str) or not name:
|
|
||||||
# Skip invalid tools to avoid Codex rejection.
|
|
||||||
continue
|
continue
|
||||||
params = params or {}
|
params = fn.get("parameters") or {}
|
||||||
if not isinstance(params, dict):
|
converted.append({
|
||||||
# Parameters must be a JSON Schema object.
|
"type": "function",
|
||||||
params = {}
|
"name": name,
|
||||||
converted.append(
|
"description": fn.get("description") or "",
|
||||||
{
|
"parameters": params if isinstance(params, dict) else {},
|
||||||
"type": "function",
|
})
|
||||||
"name": name,
|
|
||||||
"description": desc or "",
|
|
||||||
"parameters": params,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
|
||||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||||
system_prompt = ""
|
system_prompt = ""
|
||||||
input_items: list[dict[str, Any]] = []
|
input_items: list[dict[str, Any]] = []
|
||||||
@ -183,7 +175,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
call_id = _extract_call_id(msg.get("tool_call_id"))
|
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
|
||||||
output_text = content if isinstance(content, str) else json.dumps(content)
|
output_text = content if isinstance(content, str) else json.dumps(content)
|
||||||
input_items.append(
|
input_items.append(
|
||||||
{
|
{
|
||||||
@ -196,6 +188,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
|
|||||||
|
|
||||||
return system_prompt, input_items
|
return system_prompt, input_items
|
||||||
|
|
||||||
|
|
||||||
def _convert_user_message(content: Any) -> dict[str, Any]:
|
def _convert_user_message(content: Any) -> dict[str, Any]:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
|
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
|
||||||
@ -215,12 +208,6 @@ def _convert_user_message(content: Any) -> dict[str, Any]:
|
|||||||
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
||||||
|
|
||||||
|
|
||||||
def _extract_call_id(tool_call_id: Any) -> str:
|
|
||||||
if isinstance(tool_call_id, str) and tool_call_id:
|
|
||||||
return tool_call_id.split("|", 1)[0]
|
|
||||||
return "call_0"
|
|
||||||
|
|
||||||
|
|
||||||
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
||||||
if isinstance(tool_call_id, str) and tool_call_id:
|
if isinstance(tool_call_id, str) and tool_call_id:
|
||||||
if "|" in tool_call_id:
|
if "|" in tool_call_id:
|
||||||
@ -229,10 +216,12 @@ def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
|||||||
return tool_call_id, None
|
return tool_call_id, None
|
||||||
return "call_0", None
|
return "call_0", None
|
||||||
|
|
||||||
|
|
||||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||||
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
|
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
||||||
buffer: list[str] = []
|
buffer: list[str] = []
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
@ -252,6 +241,7 @@ async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any],
|
|||||||
continue
|
continue
|
||||||
buffer.append(line)
|
buffer.append(line)
|
||||||
|
|
||||||
|
|
||||||
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
|
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
|
||||||
content = ""
|
content = ""
|
||||||
tool_calls: list[ToolCallRequest] = []
|
tool_calls: list[ToolCallRequest] = []
|
||||||
@ -308,16 +298,13 @@ async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequ
|
|||||||
|
|
||||||
return content, tool_calls, finish_reason
|
return content, tool_calls, finish_reason
|
||||||
|
|
||||||
|
|
||||||
|
_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
|
||||||
|
|
||||||
|
|
||||||
def _map_finish_reason(status: str | None) -> str:
|
def _map_finish_reason(status: str | None) -> str:
|
||||||
if not status:
|
return _FINISH_REASON_MAP.get(status or "completed", "stop")
|
||||||
return "stop"
|
|
||||||
if status == "completed":
|
|
||||||
return "stop"
|
|
||||||
if status == "incomplete":
|
|
||||||
return "length"
|
|
||||||
if status in {"failed", "cancelled"}:
|
|
||||||
return "error"
|
|
||||||
return "stop"
|
|
||||||
|
|
||||||
def _friendly_error(status_code: int, raw: str) -> str:
|
def _friendly_error(status_code: int, raw: str) -> str:
|
||||||
if status_code == 429:
|
if status_code == 429:
|
||||||
|
|||||||
@ -53,7 +53,6 @@ class ProviderSpec:
|
|||||||
|
|
||||||
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
|
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
|
||||||
is_oauth: bool = False # if True, uses OAuth flow instead of API key
|
is_oauth: bool = False # if True, uses OAuth flow instead of API key
|
||||||
oauth_provider: str = "" # OAuth provider name for token retrieval
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
@ -176,7 +175,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
strip_model_prefix=False,
|
strip_model_prefix=False,
|
||||||
model_overrides=(),
|
model_overrides=(),
|
||||||
is_oauth=True, # OAuth-based authentication
|
is_oauth=True, # OAuth-based authentication
|
||||||
oauth_provider="openai-codex", # OAuth provider identifier
|
|
||||||
),
|
),
|
||||||
|
|
||||||
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
|
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user