Add Google Vertex AI with renewable ADC (#1193)
Browse files## Problem
| Before | After |
| --- | --- |
| FCC supported Google AI Studio API keys but could not route coding
agents through a Google Cloud Vertex AI project. | `vertex/...` routes
through Google's [documented OpenAI-compatible Chat Completions
endpoint](https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai),
using the global endpoint by default or an explicitly configured region.
|
| A pasted Vertex access token would expire, while Application Default
Credentials were not part of provider construction. | FCC loads
[Application Default
Credentials](https://cloud.google.com/docs/authentication/application-default-credentials),
supplies a renewable credential callback to the OpenAI transport,
coalesces concurrent refreshes, and returns typed authentication or
transient failures. |
| Vertex does not expose its model catalog through the compatible OpenAI
`/models` route. | FCC translates its generic discovery operation to
Google's paginated [publisher-model list
API](https://cloud.google.com/vertex-ai/docs/reference/rest/v1beta1/publishers.models/list)
and converts resource names into the model IDs accepted by Chat
Completions. |
| Google thought signatures were owned by the AI Studio adapter even
though Vertex shares the same protocol behavior. | A neutral Google
OpenAI family owns shared thought-signature and request behavior; AI
Studio and Vertex retain separate endpoint and authentication ownership.
|
## Changes
- Added the Vertex provider, `VERTEX_PROJECT_ID`, optional
`VERTEX_LOCATION` and `VERTEX_PROXY`, Admin UI configuration,
model-picker discovery, smoke metadata, and customer setup
documentation.
- Added renewable ADC access tokens with refresh coalescing, proxy-aware
refresh, sanitized failure classification, and project quota headers.
- Added global/regional endpoint composition plus native model-catalog
pagination, strict response validation, response cleanup, and
repeated-page protection.
- Generalized provider readiness around declared configuration fields so
project-based and multi-field providers no longer pretend every remote
provider is configured by one API key.
- Moved shared Google request quirks out of the Gemini adapter,
preserved AI Studio behavior, and bumped the package to `4.11.0`.
<!-- greptile_comment -->
<details open><summary><h3>Greptile Summary</h3></summary>
This PR adds Google Vertex AI as a new provider using Application
Default Credentials. The main changes are:
- New `vertex` provider with project/location endpoint construction.
- Renewable ADC access-token loading with refresh coalescing and
proxy-aware refresh.
- Native Vertex publisher-model discovery with pagination and response
validation.
- Shared Google OpenAI-compatible request behavior for Gemini and
Vertex.
- Admin UI, settings, smoke config, docs, version, lockfile, and tests
for the new provider.
</details>
<h3>Confidence Score: 5/5</h3>
Safe to merge with low risk.
No blocking correctness or security issues were identified. The new
provider follows the existing provider-runtime and Admin configuration
patterns. Endpoint, auth, model parsing, readiness, docs, version,
lockfile, and tests are updated together.
No files require special attention.
<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>
**What T-Rex did**
- The T-Rex test suite was executed to validate the code-execution
proof-of-work, generating a full verbose pytest log and recording the
run metadata, and the run completed with EXIT\_CODE: 0.
<a
href="https://app.greptile.com/trex/runs/14991235/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>
<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>
<details open><summary><h3>Important Files Changed</h3></summary>
| Filename | Overview |
|----------|----------|
| src/free_claude_code/providers/vertex/client.py | Adds the Vertex
provider with OpenAI-compatible chat routing and native paginated model
discovery. |
| src/free_claude_code/providers/vertex/auth.py | Implements renewable
ADC token loading, proxy-aware refresh, coalescing, and sanitized auth
failures. |
| src/free_claude_code/providers/vertex/endpoint.py | Builds validated
Vertex global/regional service, chat, and model-list endpoints. |
| src/free_claude_code/providers/vertex/models.py | Parses Vertex
publisher-model pages into OpenAI-compatible model IDs with
malformed-response checks. |
| src/free_claude_code/providers/google_openai/provider.py | Adds shared
Google thought-signature caching and thinking-budget request body
handling. |
| src/free_claude_code/providers/google_openai/quirks.py | Renames
Gemini-specific quirks to shared Google quirks and exposes model-neutral
thinking config helpers. |
| src/free_claude_code/providers/openai_chat/provider.py | Allows
OpenAI-chat providers to pass an async API-key callback into the OpenAI
SDK. |
| src/free_claude_code/providers/runtime/discovery.py | Uses
descriptor-defined readiness to choose providers eligible for model
cache/discovery. |
| src/free_claude_code/config/provider_catalog.py | Adds the Vertex
descriptor and required settings metadata, and makes Cloudflare
readiness require both token and account ID. |
| src/free_claude_code/config/admin/status.py | Generalizes Admin
provider readiness status to use each descriptor's configuration
attributes. |
| src/free_claude_code/config/admin/provider_manifest.py | Adds Admin UI
fields for Vertex project and location alongside generated provider
fields. |
| tests/providers/test_vertex.py | Adds targeted tests for Vertex
endpoints, ADC token refresh, reasoning mapping, and model discovery
pagination. |
</details>
<details open><summary><h3>Sequence Diagram</h3></summary>
<a href="#gh-light-mode-only">
```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User as User / Admin UI
participant Settings as Settings + Provider Catalog
participant Runtime as Provider Runtime
participant Vertex as VertexProvider
participant ADC as Google ADC
participant OpenAI as OpenAI-compatible Chat Endpoint
participant Models as Vertex Publisher Models API
User->>Settings: Set VERTEX_PROJECT_ID / VERTEX_LOCATION / VERTEX_PROXY
Settings->>Runtime: Descriptor reports vertex configured by project id
Runtime->>Vertex: Construct with project, location, proxy, rate limiter
Vertex->>ADC: Load/refresh Application Default Credentials
ADC-->>Vertex: Renewable access token
Vertex->>OpenAI: Stream chat completion with bearer token + x-goog-user-project
OpenAI-->>Vertex: Streaming chat chunks
Vertex-->>Runtime: Normalized provider stream
Runtime->>Vertex: Refresh model list
Vertex->>Models: GET paginated publishers/google/models
Models-->>Vertex: publisherModels + nextPageToken
Vertex-->>Runtime: Prefixed model IDs for cache/model picker
```
</a>
<a href="#gh-dark-mode-only">
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User as User / Admin UI
participant Settings as Settings + Provider Catalog
participant Runtime as Provider Runtime
participant Vertex as VertexProvider
participant ADC as Google ADC
participant OpenAI as OpenAI-compatible Chat Endpoint
participant Models as Vertex Publisher Models API
User->>Settings: Set VERTEX_PROJECT_ID / VERTEX_LOCATION / VERTEX_PROXY
Settings->>Runtime: Descriptor reports vertex configured by project id
Runtime->>Vertex: Construct with project, location, proxy, rate limiter
Vertex->>ADC: Load/refresh Application Default Credentials
ADC-->>Vertex: Renewable access token
Vertex->>OpenAI: Stream chat completion with bearer token + x-goog-user-project
OpenAI-->>Vertex: Streaming chat chunks
Vertex-->>Runtime: Normalized provider stream
Runtime->>Vertex: Refresh model list
Vertex->>Models: GET paginated publishers/google/models
Models-->>Vertex: publisherModels + nextPageToken
Vertex-->>Runtime: Prefixed model IDs for cache/model picker
```
</a>
</details>
<sub>Reviews (1): Last reviewed commit: ["feat: add Google Vertex AI
provider"](https://github.com/alishahryar1/free-claude-code/commit/97e753f0772e60377865876ca59b2fd8888d922e)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=45405432)</sub>
<!-- /greptile_comment -->
- .env.example +9 -1
- ARCHITECTURE.md +19 -3
- README.md +6 -0
- pyproject.toml +3 -1
- smoke/lib/config.py +3 -12
- src/free_claude_code/api/admin_static/admin.js +2 -2
- src/free_claude_code/config/admin/provider_manifest.py +27 -0
- src/free_claude_code/config/admin/status.py +31 -5
- src/free_claude_code/config/provider_catalog.py +28 -0
- src/free_claude_code/config/settings.py +5 -0
- src/free_claude_code/providers/gemini/client.py +3 -50
- src/free_claude_code/providers/google_openai/__init__.py +10 -0
- src/free_claude_code/providers/google_openai/provider.py +99 -0
- src/free_claude_code/providers/{gemini → google_openai}/quirks.py +21 -16
- src/free_claude_code/providers/openai_chat/__init__.py +2 -1
- src/free_claude_code/providers/openai_chat/provider.py +5 -2
- src/free_claude_code/providers/runtime/config.py +10 -0
- src/free_claude_code/providers/runtime/discovery.py +2 -6
- src/free_claude_code/providers/runtime/factory.py +16 -0
- src/free_claude_code/providers/vertex/__init__.py +5 -0
- src/free_claude_code/providers/vertex/auth.py +105 -0
- src/free_claude_code/providers/vertex/client.py +126 -0
- src/free_claude_code/providers/vertex/endpoint.py +50 -0
- src/free_claude_code/providers/vertex/models.py +52 -0
- tests/api/test_admin.py +9 -0
- tests/config/test_config.py +14 -0
- tests/config/test_provider_catalog.py +15 -0
- tests/contracts/test_admin_provider_manifest.py +29 -0
- tests/contracts/test_feature_manifest.py +2 -0
- tests/contracts/test_provider_catalog_order.py +1 -0
- tests/contracts/test_smoke_config.py +19 -0
- tests/providers/test_gemini.py +3 -3
- tests/providers/test_model_validation.py +24 -0
- tests/providers/test_provider_runtime.py +6 -0
- tests/providers/test_vertex.py +386 -0
- uv.lock +108 -1
|
@@ -76,6 +76,12 @@ CLOUDFLARE_ACCOUNT_ID=""
|
|
| 76 |
GEMINI_API_KEY=""
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# Groq Cloud (OpenAI-compatible Chat Completions; see https://console.groq.com/docs/openai)
|
| 80 |
GROQ_API_KEY=""
|
| 81 |
|
|
@@ -106,7 +112,7 @@ OLLAMA_BASE_URL="http://localhost:11434"
|
|
| 106 |
|
| 107 |
# All Claude model requests are mapped to these models, plain model is fallback
|
| 108 |
# Format: provider_type/model/name
|
| 109 |
-
# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "bedrock" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "kimi_code" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "ollama_cloud" | "lmstudio" | "llamacpp" | "ollama"
|
| 110 |
MODEL_FABLE=
|
| 111 |
MODEL_OPUS=
|
| 112 |
MODEL_SONNET=
|
|
@@ -141,6 +147,7 @@ FCC_SMOKE_MODEL_ZAI=
|
|
| 141 |
FCC_SMOKE_MODEL_FIREWORKS=
|
| 142 |
FCC_SMOKE_MODEL_CLOUDFLARE=
|
| 143 |
FCC_SMOKE_MODEL_GEMINI=
|
|
|
|
| 144 |
FCC_SMOKE_MODEL_GROQ=
|
| 145 |
FCC_SMOKE_MODEL_SAMBANOVA=
|
| 146 |
FCC_SMOKE_MODEL_CEREBRAS=
|
|
@@ -184,6 +191,7 @@ ZAI_PROXY=""
|
|
| 184 |
FIREWORKS_PROXY=""
|
| 185 |
CLOUDFLARE_PROXY=""
|
| 186 |
GEMINI_PROXY=""
|
|
|
|
| 187 |
GROQ_PROXY=""
|
| 188 |
SAMBANOVA_PROXY=""
|
| 189 |
CEREBRAS_PROXY=""
|
|
|
|
| 76 |
GEMINI_API_KEY=""
|
| 77 |
|
| 78 |
|
| 79 |
+
# Google Vertex AI (uses Application Default Credentials; no API key)
|
| 80 |
+
# Local setup: gcloud auth application-default login
|
| 81 |
+
VERTEX_PROJECT_ID=""
|
| 82 |
+
VERTEX_LOCATION="global"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
# Groq Cloud (OpenAI-compatible Chat Completions; see https://console.groq.com/docs/openai)
|
| 86 |
GROQ_API_KEY=""
|
| 87 |
|
|
|
|
| 112 |
|
| 113 |
# All Claude model requests are mapped to these models, plain model is fallback
|
| 114 |
# Format: provider_type/model/name
|
| 115 |
+
# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "vertex" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "bedrock" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "kimi_code" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "ollama_cloud" | "lmstudio" | "llamacpp" | "ollama"
|
| 116 |
MODEL_FABLE=
|
| 117 |
MODEL_OPUS=
|
| 118 |
MODEL_SONNET=
|
|
|
|
| 147 |
FCC_SMOKE_MODEL_FIREWORKS=
|
| 148 |
FCC_SMOKE_MODEL_CLOUDFLARE=
|
| 149 |
FCC_SMOKE_MODEL_GEMINI=
|
| 150 |
+
FCC_SMOKE_MODEL_VERTEX=
|
| 151 |
FCC_SMOKE_MODEL_GROQ=
|
| 152 |
FCC_SMOKE_MODEL_SAMBANOVA=
|
| 153 |
FCC_SMOKE_MODEL_CEREBRAS=
|
|
|
|
| 191 |
FIREWORKS_PROXY=""
|
| 192 |
CLOUDFLARE_PROXY=""
|
| 193 |
GEMINI_PROXY=""
|
| 194 |
+
VERTEX_PROXY=""
|
| 195 |
GROQ_PROXY=""
|
| 196 |
SAMBANOVA_PROXY=""
|
| 197 |
CEREBRAS_PROXY=""
|
|
@@ -490,6 +490,10 @@ request-scoped policy passed to execution.
|
|
| 490 |
|
| 491 |
Provider model discovery and optional thinking metadata live in the
|
| 492 |
application-level catalog owned by `ProviderRuntimeManager`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
`ProviderModelInfo.supports_thinking` alone owns discovered per-model thinking
|
| 494 |
support for model-list presentation; it does not select request behavior.
|
| 495 |
Provider adapters must never branch on upstream model names or versions to
|
|
@@ -508,8 +512,10 @@ passes it as `model_catalog_json`. Codex users open the native picker with
|
|
| 508 |
Provider metadata is neutral and centralized in
|
| 509 |
[config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py). Each
|
| 510 |
`ProviderDescriptor` declares provider ID, display name, locality, credential env
|
| 511 |
-
var, default base URL, settings attribute names,
|
| 512 |
-
|
|
|
|
|
|
|
| 513 |
|
| 514 |
[providers/runtime/](src/free_claude_code/providers/runtime/) owns construction details for one
|
| 515 |
closable provider generation: construction policy, resolved provider
|
|
@@ -554,7 +560,7 @@ compatibility layer.
|
|
| 554 |
- `BaseProvider`: the abstract implementation base for cleanup, model listing,
|
| 555 |
explicit preflight, and `stream_response()`.
|
| 556 |
|
| 557 |
-
There is one upstream
|
| 558 |
[providers/openai_chat/](src/free_claude_code/providers/openai_chat/) implements the concrete
|
| 559 |
`OpenAIChatProvider` used by every OpenAI-compatible `/chat/completions`
|
| 560 |
upstream. `OpenAIChatProfile` contains immutable request policy, an explicit
|
|
@@ -566,6 +572,16 @@ owns the exactly typed private per-request runner, recovery operations, tool-cal
|
|
| 566 |
assembly, and streamed usage handling. No obsolete generic transport namespace
|
| 567 |
or untyped provider backchannel remains.
|
| 568 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
`OpenAIChatProvider` explicitly implements preflight by constructing the same
|
| 570 |
upstream request body it will later stream. `BaseProvider` makes that operation
|
| 571 |
abstract, so a new provider cannot silently omit the commit-boundary validation.
|
|
|
|
| 490 |
|
| 491 |
Provider model discovery and optional thinking metadata live in the
|
| 492 |
application-level catalog owned by `ProviderRuntimeManager`.
|
| 493 |
+
Discovery is an adapter operation, not an assumption that every upstream has an
|
| 494 |
+
OpenAI `/models` route. For example, Vertex translates that operation to
|
| 495 |
+
Google's paginated `publishers/google/models` API and converts publisher resource
|
| 496 |
+
names into the exact model IDs accepted by its OpenAI-compatible endpoint.
|
| 497 |
`ProviderModelInfo.supports_thinking` alone owns discovered per-model thinking
|
| 498 |
support for model-list presentation; it does not select request behavior.
|
| 499 |
Provider adapters must never branch on upstream model names or versions to
|
|
|
|
| 512 |
Provider metadata is neutral and centralized in
|
| 513 |
[config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py). Each
|
| 514 |
`ProviderDescriptor` declares provider ID, display name, locality, credential env
|
| 515 |
+
var, default base URL, settings attribute names, configuration readiness, and
|
| 516 |
+
proxy support. Readiness may require multiple ordinary settings or a non-secret
|
| 517 |
+
project ID; it is not inferred exclusively from API-key presence. The catalog
|
| 518 |
+
does not select a concrete adapter.
|
| 519 |
|
| 520 |
[providers/runtime/](src/free_claude_code/providers/runtime/) owns construction details for one
|
| 521 |
closable provider generation: construction policy, resolved provider
|
|
|
|
| 560 |
- `BaseProvider`: the abstract implementation base for cleanup, model listing,
|
| 561 |
explicit preflight, and `stream_response()`.
|
| 562 |
|
| 563 |
+
There is one upstream transport family:
|
| 564 |
[providers/openai_chat/](src/free_claude_code/providers/openai_chat/) implements the concrete
|
| 565 |
`OpenAIChatProvider` used by every OpenAI-compatible `/chat/completions`
|
| 566 |
upstream. `OpenAIChatProfile` contains immutable request policy, an explicit
|
|
|
|
| 572 |
assembly, and streamed usage handling. No obsolete generic transport namespace
|
| 573 |
or untyped provider backchannel remains.
|
| 574 |
|
| 575 |
+
[providers/google_openai/](src/free_claude_code/providers/google_openai/) owns the
|
| 576 |
+
Google-specific protocol behavior shared by AI Studio and Vertex AI: literal
|
| 577 |
+
Google `extra_body` construction, thought-signature replay, and thinking-budget
|
| 578 |
+
encoding. Neither concrete provider imports from the other. AI Studio owns its
|
| 579 |
+
API-key endpoint; [providers/vertex/](src/free_claude_code/providers/vertex/)
|
| 580 |
+
owns project/location endpoint composition, renewable Application Default
|
| 581 |
+
Credentials, and translation of Google's native publisher-model catalog. The
|
| 582 |
+
OpenAI transport receives a callable credential source, so access-token refresh
|
| 583 |
+
does not require rebuilding provider generations or persisting ephemeral tokens.
|
| 584 |
+
|
| 585 |
`OpenAIChatProvider` explicitly implements preflight by constructing the same
|
| 586 |
upstream request body it will later stream. `BaseProvider` makes that operation
|
| 587 |
abstract, so a new provider cannot silently omit the commit-boundary validation.
|
|
@@ -140,6 +140,7 @@ Enter the listed setting in the Admin UI, open **Model Config**, then search the
|
|
| 140 |
| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` |
|
| 141 |
| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` |
|
| 142 |
| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` |
|
|
|
|
| 143 |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `DEEPSEEK_API_KEY` | `deepseek/deepseek-chat` |
|
| 144 |
| [Mistral La Plateforme](https://console.mistral.ai/) | `MISTRAL_API_KEY` | `mistral/devstral-small-latest` |
|
| 145 |
| [Mistral Codestral](https://console.mistral.ai/) | `CODESTRAL_API_KEY` | `mistral_codestral/codestral-latest` |
|
|
@@ -175,6 +176,11 @@ Important provider notes:
|
|
| 175 |
- Amazon Bedrock uses its Mantle OpenAI-compatible endpoint. Set
|
| 176 |
`BEDROCK_BASE_URL` to the endpoint for the same region as the API key and
|
| 177 |
select one of the models returned by FCC's model picker.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
- Cloudflare requires both its API token and account ID.
|
| 179 |
- Ollama Cloud connects directly to `ollama.com`; use the exact model IDs shown
|
| 180 |
by FCC's model picker. Local Ollama remains available through the separate
|
|
|
|
| 140 |
| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` |
|
| 141 |
| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` |
|
| 142 |
| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` |
|
| 143 |
+
| [Google Vertex AI](https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai) | `VERTEX_PROJECT_ID` + ADC | `vertex/google/gemini-3.5-flash` |
|
| 144 |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `DEEPSEEK_API_KEY` | `deepseek/deepseek-chat` |
|
| 145 |
| [Mistral La Plateforme](https://console.mistral.ai/) | `MISTRAL_API_KEY` | `mistral/devstral-small-latest` |
|
| 146 |
| [Mistral Codestral](https://console.mistral.ai/) | `CODESTRAL_API_KEY` | `mistral_codestral/codestral-latest` |
|
|
|
|
| 176 |
- Amazon Bedrock uses its Mantle OpenAI-compatible endpoint. Set
|
| 177 |
`BEDROCK_BASE_URL` to the endpoint for the same region as the API key and
|
| 178 |
select one of the models returned by FCC's model picker.
|
| 179 |
+
- Vertex AI uses Google Application Default Credentials instead of an API key.
|
| 180 |
+
Locally, run `gcloud auth application-default login` once; service-account
|
| 181 |
+
files and attached service accounts also work. Set `VERTEX_PROJECT_ID`, and
|
| 182 |
+
optionally change `VERTEX_LOCATION` from its `global` default. FCC refreshes
|
| 183 |
+
expiring access tokens automatically.
|
| 184 |
- Cloudflare requires both its API token and account ID.
|
| 185 |
- Ollama Cloud connects directly to `ollama.com`; use the exact model IDs shown
|
| 186 |
by FCC's model picker. Local Ollama remains available through the separate
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "4.
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
@@ -23,6 +23,8 @@ dependencies = [
|
|
| 23 |
"loguru>=0.7.0",
|
| 24 |
"aiohttp>=3.14.1",
|
| 25 |
"jsonschema>=4.25.0",
|
|
|
|
|
|
|
| 26 |
]
|
| 27 |
|
| 28 |
[project.scripts]
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
+
version = "4.11.0"
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
|
|
| 23 |
"loguru>=0.7.0",
|
| 24 |
"aiohttp>=3.14.1",
|
| 25 |
"jsonschema>=4.25.0",
|
| 26 |
+
"google-auth[requests]>=2.40.0",
|
| 27 |
+
"requests[socks]>=2.32.0",
|
| 28 |
]
|
| 29 |
|
| 30 |
[project.scripts]
|
|
@@ -11,6 +11,7 @@ from free_claude_code.config.provider_catalog import (
|
|
| 11 |
SUPPORTED_PROVIDER_IDS,
|
| 12 |
)
|
| 13 |
from free_claude_code.config.settings import Settings, get_settings
|
|
|
|
| 14 |
|
| 15 |
DEFAULT_TARGETS = frozenset(
|
| 16 |
{
|
|
@@ -65,6 +66,7 @@ PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
|
|
| 65 |
"github_models": "github_models/openai/gpt-4.1",
|
| 66 |
"zai": "zai/glm-5.2",
|
| 67 |
"gemini": "gemini/models/gemini-3.1-flash-lite",
|
|
|
|
| 68 |
"groq": "groq/llama-3.3-70b-versatile",
|
| 69 |
"sambanova": "sambanova/Meta-Llama-3.3-70B-Instruct",
|
| 70 |
"cerebras": "cerebras/llama3.1-8b",
|
|
@@ -252,21 +254,10 @@ class SmokeConfig:
|
|
| 252 |
return bool(os.getenv(f"FCC_SMOKE_MODEL_{provider.upper()}"))
|
| 253 |
|
| 254 |
def has_provider_configuration(self, provider: str) -> bool:
|
| 255 |
-
if provider == "cloudflare":
|
| 256 |
-
return bool(
|
| 257 |
-
self.settings.cloudflare_api_token.strip()
|
| 258 |
-
and self.settings.cloudflare_account_id.strip()
|
| 259 |
-
)
|
| 260 |
descriptor = PROVIDER_CATALOG.get(provider)
|
| 261 |
if descriptor is None:
|
| 262 |
return False
|
| 263 |
-
|
| 264 |
-
credential = getattr(self.settings, descriptor.credential_attr, "")
|
| 265 |
-
return isinstance(credential, str) and bool(credential.strip())
|
| 266 |
-
if descriptor.base_url_attr:
|
| 267 |
-
base_url = getattr(self.settings, descriptor.base_url_attr, "")
|
| 268 |
-
return isinstance(base_url, str) and bool(base_url.strip())
|
| 269 |
-
return descriptor.static_credential is not None
|
| 270 |
|
| 271 |
|
| 272 |
def _parse_csv(raw: str | None) -> frozenset[str]:
|
|
|
|
| 11 |
SUPPORTED_PROVIDER_IDS,
|
| 12 |
)
|
| 13 |
from free_claude_code.config.settings import Settings, get_settings
|
| 14 |
+
from free_claude_code.providers.runtime.config import has_provider_configuration
|
| 15 |
|
| 16 |
DEFAULT_TARGETS = frozenset(
|
| 17 |
{
|
|
|
|
| 66 |
"github_models": "github_models/openai/gpt-4.1",
|
| 67 |
"zai": "zai/glm-5.2",
|
| 68 |
"gemini": "gemini/models/gemini-3.1-flash-lite",
|
| 69 |
+
"vertex": "vertex/google/gemini-3.5-flash",
|
| 70 |
"groq": "groq/llama-3.3-70b-versatile",
|
| 71 |
"sambanova": "sambanova/Meta-Llama-3.3-70B-Instruct",
|
| 72 |
"cerebras": "cerebras/llama3.1-8b",
|
|
|
|
| 254 |
return bool(os.getenv(f"FCC_SMOKE_MODEL_{provider.upper()}"))
|
| 255 |
|
| 256 |
def has_provider_configuration(self, provider: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
descriptor = PROVIDER_CATALOG.get(provider)
|
| 258 |
if descriptor is None:
|
| 259 |
return False
|
| 260 |
+
return has_provider_configuration(descriptor, self.settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
|
| 263 |
def _parse_csv(raw: str | None) -> frozenset[str]:
|
|
@@ -60,7 +60,7 @@ function sourceText(field) {
|
|
| 60 |
|
| 61 |
function statusClass(status) {
|
| 62 |
if (["configured", "reachable", "running"].includes(status)) return "ok";
|
| 63 |
-
if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn";
|
| 64 |
if (["offline", "error"].includes(status)) return "error";
|
| 65 |
return "neutral";
|
| 66 |
}
|
|
@@ -162,7 +162,7 @@ function renderProviders(providerStatus) {
|
|
| 162 |
meta.textContent =
|
| 163 |
provider.kind === "local"
|
| 164 |
? provider.base_url || "No local URL configured"
|
| 165 |
-
: provider.
|
| 166 |
|
| 167 |
const button = document.createElement("button");
|
| 168 |
button.type = "button";
|
|
|
|
| 60 |
|
| 61 |
function statusClass(status) {
|
| 62 |
if (["configured", "reachable", "running"].includes(status)) return "ok";
|
| 63 |
+
if (["missing_key", "missing_config", "missing_url", "unknown"].includes(status)) return "warn";
|
| 64 |
if (["offline", "error"].includes(status)) return "error";
|
| 65 |
return "neutral";
|
| 66 |
}
|
|
|
|
| 162 |
meta.textContent =
|
| 163 |
provider.kind === "local"
|
| 164 |
? provider.base_url || "No local URL configured"
|
| 165 |
+
: provider.configuration;
|
| 166 |
|
| 167 |
const button = document.createElement("button");
|
| 168 |
button.type = "button";
|
|
@@ -143,6 +143,7 @@ def provider_field_specs() -> tuple[dict[str, Any], ...]:
|
|
| 143 |
return (
|
| 144 |
*_credential_field_specs(),
|
| 145 |
*_cloudflare_account_field_specs(),
|
|
|
|
| 146 |
*_base_url_field_specs(),
|
| 147 |
*_proxy_field_specs(),
|
| 148 |
)
|
|
@@ -202,6 +203,32 @@ def _cloudflare_account_field_specs() -> tuple[dict[str, Any], ...]:
|
|
| 202 |
)
|
| 203 |
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
def _proxy_field_specs() -> tuple[dict[str, Any], ...]:
|
| 206 |
specs: list[dict[str, Any]] = []
|
| 207 |
for descriptor in PROVIDER_CATALOG.values():
|
|
|
|
| 143 |
return (
|
| 144 |
*_credential_field_specs(),
|
| 145 |
*_cloudflare_account_field_specs(),
|
| 146 |
+
*_vertex_field_specs(),
|
| 147 |
*_base_url_field_specs(),
|
| 148 |
*_proxy_field_specs(),
|
| 149 |
)
|
|
|
|
| 203 |
)
|
| 204 |
|
| 205 |
|
| 206 |
+
def _vertex_field_specs() -> tuple[dict[str, Any], ...]:
|
| 207 |
+
return (
|
| 208 |
+
{
|
| 209 |
+
"key": "VERTEX_PROJECT_ID",
|
| 210 |
+
"label": "Google Cloud Project ID",
|
| 211 |
+
"section_id": "providers",
|
| 212 |
+
"settings_attr": "vertex_project_id",
|
| 213 |
+
"description": (
|
| 214 |
+
"Google Cloud project used for Vertex AI. Authentication uses "
|
| 215 |
+
"Application Default Credentials (ADC)."
|
| 216 |
+
),
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"key": "VERTEX_LOCATION",
|
| 220 |
+
"label": "Vertex AI Location",
|
| 221 |
+
"section_id": "providers",
|
| 222 |
+
"settings_attr": "vertex_location",
|
| 223 |
+
"default": "global",
|
| 224 |
+
"description": (
|
| 225 |
+
"Use global for the global Vertex AI endpoint or a region such as "
|
| 226 |
+
"us-central1."
|
| 227 |
+
),
|
| 228 |
+
},
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
def _proxy_field_specs() -> tuple[dict[str, Any], ...]:
|
| 233 |
specs: list[dict[str, Any]] = []
|
| 234 |
for descriptor in PROVIDER_CATALOG.values():
|
|
@@ -30,16 +30,35 @@ def provider_config_status(
|
|
| 30 |
)
|
| 31 |
continue
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
statuses.append(
|
| 36 |
{
|
| 37 |
"provider_id": provider_id,
|
| 38 |
"display_name": descriptor.display_name,
|
| 39 |
"kind": "remote",
|
| 40 |
-
"status":
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
)
|
| 45 |
return statuses
|
|
@@ -52,3 +71,10 @@ def _value_for_settings_attr(
|
|
| 52 |
if field.settings_attr == settings_attr:
|
| 53 |
return str(state.get(field.key, {}).get("value", field.default))
|
| 54 |
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
)
|
| 31 |
continue
|
| 32 |
|
| 33 |
+
configured = all(
|
| 34 |
+
_value_for_settings_attr(state, attr).strip()
|
| 35 |
+
for attr in descriptor.configuration_attrs()
|
| 36 |
+
)
|
| 37 |
+
configuration = " + ".join(
|
| 38 |
+
_field_key_for_settings_attr(attr)
|
| 39 |
+
for attr in descriptor.configuration_attrs()
|
| 40 |
+
)
|
| 41 |
+
missing_key = descriptor.credential_env is not None
|
| 42 |
statuses.append(
|
| 43 |
{
|
| 44 |
"provider_id": provider_id,
|
| 45 |
"display_name": descriptor.display_name,
|
| 46 |
"kind": "remote",
|
| 47 |
+
"status": (
|
| 48 |
+
"configured"
|
| 49 |
+
if configured
|
| 50 |
+
else "missing_key"
|
| 51 |
+
if missing_key
|
| 52 |
+
else "missing_config"
|
| 53 |
+
),
|
| 54 |
+
"label": (
|
| 55 |
+
"Configured"
|
| 56 |
+
if configured
|
| 57 |
+
else "Missing key"
|
| 58 |
+
if missing_key
|
| 59 |
+
else "Missing configuration"
|
| 60 |
+
),
|
| 61 |
+
"configuration": configuration,
|
| 62 |
}
|
| 63 |
)
|
| 64 |
return statuses
|
|
|
|
| 71 |
if field.settings_attr == settings_attr:
|
| 72 |
return str(state.get(field.key, {}).get("value", field.default))
|
| 73 |
return ""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _field_key_for_settings_attr(settings_attr: str) -> str:
|
| 77 |
+
for field in FIELDS:
|
| 78 |
+
if field.settings_attr == settings_attr:
|
| 79 |
+
return field.key
|
| 80 |
+
raise AssertionError(f"No admin field owns settings attribute {settings_attr!r}")
|
|
@@ -40,6 +40,8 @@ GITHUB_MODELS_DEFAULT_BASE = "https://models.github.ai/inference"
|
|
| 40 |
ZAI_DEFAULT_BASE = "https://api.z.ai/api/coding/paas/v4"
|
| 41 |
# Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI).
|
| 42 |
GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
|
|
|
|
|
|
| 43 |
GROQ_DEFAULT_BASE = "https://api.groq.com/openai/v1"
|
| 44 |
CEREBRAS_DEFAULT_BASE = "https://api.cerebras.ai/v1"
|
| 45 |
SAMBANOVA_DEFAULT_BASE = "https://api.sambanova.ai/v1"
|
|
@@ -59,6 +61,17 @@ class ProviderDescriptor:
|
|
| 59 |
default_base_url: str | None = None
|
| 60 |
base_url_attr: str | None = None
|
| 61 |
proxy_attr: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
@@ -89,6 +102,17 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
| 89 |
default_base_url=GEMINI_DEFAULT_BASE,
|
| 90 |
proxy_attr="gemini_proxy",
|
| 91 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"deepseek": ProviderDescriptor(
|
| 93 |
provider_id="deepseek",
|
| 94 |
display_name="DeepSeek",
|
|
@@ -259,6 +283,10 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
| 259 |
credential_attr="cloudflare_api_token",
|
| 260 |
default_base_url=CLOUDFLARE_AI_REST_ROOT,
|
| 261 |
proxy_attr="cloudflare_proxy",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
),
|
| 263 |
"zai": ProviderDescriptor(
|
| 264 |
provider_id="zai",
|
|
|
|
| 40 |
ZAI_DEFAULT_BASE = "https://api.z.ai/api/coding/paas/v4"
|
| 41 |
# Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI).
|
| 42 |
GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
| 43 |
+
# Vertex AI API root. The provider owns project/location endpoint composition.
|
| 44 |
+
VERTEX_AI_API_ROOT = "https://aiplatform.googleapis.com"
|
| 45 |
GROQ_DEFAULT_BASE = "https://api.groq.com/openai/v1"
|
| 46 |
CEREBRAS_DEFAULT_BASE = "https://api.cerebras.ai/v1"
|
| 47 |
SAMBANOVA_DEFAULT_BASE = "https://api.sambanova.ai/v1"
|
|
|
|
| 61 |
default_base_url: str | None = None
|
| 62 |
base_url_attr: str | None = None
|
| 63 |
proxy_attr: str | None = None
|
| 64 |
+
required_settings_attrs: tuple[str, ...] = ()
|
| 65 |
+
|
| 66 |
+
def configuration_attrs(self) -> tuple[str, ...]:
|
| 67 |
+
"""Return settings fields whose non-empty values configure this provider."""
|
| 68 |
+
if self.required_settings_attrs:
|
| 69 |
+
return self.required_settings_attrs
|
| 70 |
+
if self.credential_attr is not None:
|
| 71 |
+
return (self.credential_attr,)
|
| 72 |
+
if self.base_url_attr is not None:
|
| 73 |
+
return (self.base_url_attr,)
|
| 74 |
+
return ()
|
| 75 |
|
| 76 |
|
| 77 |
PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
|
|
|
|
| 102 |
default_base_url=GEMINI_DEFAULT_BASE,
|
| 103 |
proxy_attr="gemini_proxy",
|
| 104 |
),
|
| 105 |
+
"vertex": ProviderDescriptor(
|
| 106 |
+
provider_id="vertex",
|
| 107 |
+
display_name="Google Vertex AI",
|
| 108 |
+
credential_url=(
|
| 109 |
+
"https://cloud.google.com/docs/authentication/"
|
| 110 |
+
"set-up-adc-local-dev-environment"
|
| 111 |
+
),
|
| 112 |
+
default_base_url=VERTEX_AI_API_ROOT,
|
| 113 |
+
proxy_attr="vertex_proxy",
|
| 114 |
+
required_settings_attrs=("vertex_project_id",),
|
| 115 |
+
),
|
| 116 |
"deepseek": ProviderDescriptor(
|
| 117 |
provider_id="deepseek",
|
| 118 |
display_name="DeepSeek",
|
|
|
|
| 283 |
credential_attr="cloudflare_api_token",
|
| 284 |
default_base_url=CLOUDFLARE_AI_REST_ROOT,
|
| 285 |
proxy_attr="cloudflare_proxy",
|
| 286 |
+
required_settings_attrs=(
|
| 287 |
+
"cloudflare_api_token",
|
| 288 |
+
"cloudflare_account_id",
|
| 289 |
+
),
|
| 290 |
),
|
| 291 |
"zai": ProviderDescriptor(
|
| 292 |
provider_id="zai",
|
|
@@ -91,6 +91,10 @@ class Settings(BaseSettings):
|
|
| 91 |
# ==================== Google Gemini (Google AI Studio) ====================
|
| 92 |
gemini_api_key: str = Field(default="", validation_alias="GEMINI_API_KEY")
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# ==================== Groq (OpenAI-compatible) ====================
|
| 95 |
groq_api_key: str = Field(default="", validation_alias="GROQ_API_KEY")
|
| 96 |
|
|
@@ -170,6 +174,7 @@ class Settings(BaseSettings):
|
|
| 170 |
fireworks_proxy: str = Field(default="", validation_alias="FIREWORKS_PROXY")
|
| 171 |
cloudflare_proxy: str = Field(default="", validation_alias="CLOUDFLARE_PROXY")
|
| 172 |
gemini_proxy: str = Field(default="", validation_alias="GEMINI_PROXY")
|
|
|
|
| 173 |
groq_proxy: str = Field(default="", validation_alias="GROQ_PROXY")
|
| 174 |
cerebras_proxy: str = Field(default="", validation_alias="CEREBRAS_PROXY")
|
| 175 |
ollama_cloud_proxy: str = Field(default="", validation_alias="OLLAMA_CLOUD_PROXY")
|
|
|
|
| 91 |
# ==================== Google Gemini (Google AI Studio) ====================
|
| 92 |
gemini_api_key: str = Field(default="", validation_alias="GEMINI_API_KEY")
|
| 93 |
|
| 94 |
+
# ==================== Google Vertex AI ====================
|
| 95 |
+
vertex_project_id: str = Field(default="", validation_alias="VERTEX_PROJECT_ID")
|
| 96 |
+
vertex_location: str = Field(default="global", validation_alias="VERTEX_LOCATION")
|
| 97 |
+
|
| 98 |
# ==================== Groq (OpenAI-compatible) ====================
|
| 99 |
groq_api_key: str = Field(default="", validation_alias="GROQ_API_KEY")
|
| 100 |
|
|
|
|
| 174 |
fireworks_proxy: str = Field(default="", validation_alias="FIREWORKS_PROXY")
|
| 175 |
cloudflare_proxy: str = Field(default="", validation_alias="CLOUDFLARE_PROXY")
|
| 176 |
gemini_proxy: str = Field(default="", validation_alias="GEMINI_PROXY")
|
| 177 |
+
vertex_proxy: str = Field(default="", validation_alias="VERTEX_PROXY")
|
| 178 |
groq_proxy: str = Field(default="", validation_alias="GROQ_PROXY")
|
| 179 |
cerebras_proxy: str = Field(default="", validation_alias="CEREBRAS_PROXY")
|
| 180 |
ollama_cloud_proxy: str = Field(default="", validation_alias="OLLAMA_CLOUD_PROXY")
|
|
@@ -1,28 +1,16 @@
|
|
| 1 |
"""Google AI Studio Gemini provider (OpenAI-compatible chat completions)."""
|
| 2 |
|
| 3 |
-
from copy import deepcopy
|
| 4 |
-
from typing import Any
|
| 5 |
-
|
| 6 |
from free_claude_code.core.anthropic import ReasoningReplayMode
|
| 7 |
-
from free_claude_code.core.
|
| 8 |
-
from free_claude_code.core.reasoning import (
|
| 9 |
-
DEFAULT_REASONING_POLICY,
|
| 10 |
-
ReasoningEffort,
|
| 11 |
-
ReasoningPolicy,
|
| 12 |
-
)
|
| 13 |
from free_claude_code.providers.base import ProviderConfig
|
|
|
|
| 14 |
from free_claude_code.providers.openai_chat import (
|
| 15 |
NamedEffortReasoning,
|
| 16 |
OpenAIChatProfile,
|
| 17 |
-
OpenAIChatProvider,
|
| 18 |
OpenAIChatRequestPolicy,
|
| 19 |
-
build_openai_chat_request_body,
|
| 20 |
)
|
| 21 |
from free_claude_code.providers.rate_limit import ProviderRateLimiter
|
| 22 |
|
| 23 |
-
from .quirks import apply_gemini_request_quirks
|
| 24 |
-
|
| 25 |
-
_MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 4096
|
| 26 |
_REQUEST_POLICY = OpenAIChatRequestPolicy(
|
| 27 |
provider_name="GEMINI",
|
| 28 |
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT,
|
|
@@ -43,7 +31,7 @@ _PROFILE = OpenAIChatProfile(
|
|
| 43 |
)
|
| 44 |
|
| 45 |
|
| 46 |
-
class GeminiProvider(
|
| 47 |
"""Gemini API using ``https://generativelanguage.googleapis.com/v1beta/openai/``."""
|
| 48 |
|
| 49 |
def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
|
|
@@ -52,38 +40,3 @@ class GeminiProvider(OpenAIChatProvider):
|
|
| 52 |
profile=_PROFILE,
|
| 53 |
rate_limiter=rate_limiter,
|
| 54 |
)
|
| 55 |
-
self._tool_call_extra_content_by_id: dict[str, dict[str, Any]] = {}
|
| 56 |
-
|
| 57 |
-
def _record_tool_call_extra_content(
|
| 58 |
-
self, tool_call_id: str, extra_content: dict[str, Any]
|
| 59 |
-
) -> None:
|
| 60 |
-
if (
|
| 61 |
-
tool_call_id not in self._tool_call_extra_content_by_id
|
| 62 |
-
and len(self._tool_call_extra_content_by_id)
|
| 63 |
-
>= _MAX_TOOL_CALL_EXTRA_CONTENT_CACHE
|
| 64 |
-
):
|
| 65 |
-
self._tool_call_extra_content_by_id.pop(
|
| 66 |
-
next(iter(self._tool_call_extra_content_by_id))
|
| 67 |
-
)
|
| 68 |
-
self._tool_call_extra_content_by_id[tool_call_id] = deepcopy(extra_content)
|
| 69 |
-
|
| 70 |
-
def _build_request_body(
|
| 71 |
-
self,
|
| 72 |
-
request: MessagesRequest,
|
| 73 |
-
*,
|
| 74 |
-
reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY,
|
| 75 |
-
) -> dict:
|
| 76 |
-
return build_openai_chat_request_body(
|
| 77 |
-
request,
|
| 78 |
-
reasoning=reasoning,
|
| 79 |
-
policy=_REQUEST_POLICY,
|
| 80 |
-
postprocessors=(
|
| 81 |
-
lambda body, request_data, policy: apply_gemini_request_quirks(
|
| 82 |
-
body,
|
| 83 |
-
request_data,
|
| 84 |
-
policy,
|
| 85 |
-
tool_call_extra_content_by_id=self._tool_call_extra_content_by_id,
|
| 86 |
-
),
|
| 87 |
-
_PROFILE.apply_reasoning,
|
| 88 |
-
),
|
| 89 |
-
)
|
|
|
|
| 1 |
"""Google AI Studio Gemini provider (OpenAI-compatible chat completions)."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
from free_claude_code.core.anthropic import ReasoningReplayMode
|
| 4 |
+
from free_claude_code.core.reasoning import ReasoningEffort
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from free_claude_code.providers.base import ProviderConfig
|
| 6 |
+
from free_claude_code.providers.google_openai import GoogleOpenAIProvider
|
| 7 |
from free_claude_code.providers.openai_chat import (
|
| 8 |
NamedEffortReasoning,
|
| 9 |
OpenAIChatProfile,
|
|
|
|
| 10 |
OpenAIChatRequestPolicy,
|
|
|
|
| 11 |
)
|
| 12 |
from free_claude_code.providers.rate_limit import ProviderRateLimiter
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
_REQUEST_POLICY = OpenAIChatRequestPolicy(
|
| 15 |
provider_name="GEMINI",
|
| 16 |
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT,
|
|
|
|
| 31 |
)
|
| 32 |
|
| 33 |
|
| 34 |
+
class GeminiProvider(GoogleOpenAIProvider):
|
| 35 |
"""Gemini API using ``https://generativelanguage.googleapis.com/v1beta/openai/``."""
|
| 36 |
|
| 37 |
def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
|
|
|
|
| 40 |
profile=_PROFILE,
|
| 41 |
rate_limiter=rate_limiter,
|
| 42 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Google OpenAI-compatible provider family."""
|
| 2 |
+
|
| 3 |
+
from .provider import GoogleOpenAIProvider, GoogleThinkingBudgetReasoning
|
| 4 |
+
from .quirks import GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR",
|
| 8 |
+
"GoogleOpenAIProvider",
|
| 9 |
+
"GoogleThinkingBudgetReasoning",
|
| 10 |
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Google behavior for OpenAI-compatible Gemini endpoints."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Mapping
|
| 4 |
+
from copy import deepcopy
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from free_claude_code.core.anthropic.models import MessagesRequest
|
| 9 |
+
from free_claude_code.core.reasoning import (
|
| 10 |
+
DEFAULT_REASONING_POLICY,
|
| 11 |
+
ReasoningControl,
|
| 12 |
+
ReasoningPolicy,
|
| 13 |
+
)
|
| 14 |
+
from free_claude_code.providers.base import ProviderConfig
|
| 15 |
+
from free_claude_code.providers.openai_chat import (
|
| 16 |
+
OpenAIAsyncCredentialProvider,
|
| 17 |
+
OpenAIChatProfile,
|
| 18 |
+
OpenAIChatProvider,
|
| 19 |
+
build_openai_chat_request_body,
|
| 20 |
+
)
|
| 21 |
+
from free_claude_code.providers.rate_limit import ProviderRateLimiter
|
| 22 |
+
|
| 23 |
+
from .quirks import apply_google_request_quirks, google_thinking_config
|
| 24 |
+
|
| 25 |
+
_MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 4096
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True, slots=True)
|
| 29 |
+
class GoogleThinkingBudgetReasoning:
|
| 30 |
+
"""Encode FCC reasoning intent in Google's model-neutral thinking budget."""
|
| 31 |
+
|
| 32 |
+
def encode(self, body: dict[str, Any], policy: ReasoningPolicy) -> None:
|
| 33 |
+
if policy.control is ReasoningControl.OFF:
|
| 34 |
+
thinking = google_thinking_config(body)
|
| 35 |
+
thinking["thinking_budget"] = 0
|
| 36 |
+
thinking["include_thoughts"] = False
|
| 37 |
+
return
|
| 38 |
+
budget = policy.numeric_budget_tokens
|
| 39 |
+
if budget is None:
|
| 40 |
+
return
|
| 41 |
+
thinking = google_thinking_config(body)
|
| 42 |
+
thinking.setdefault("thinking_budget", budget)
|
| 43 |
+
thinking.setdefault("include_thoughts", True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class GoogleOpenAIProvider(OpenAIChatProvider):
|
| 47 |
+
"""Shared thought-signature and request behavior for Google Gemini APIs."""
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
config: ProviderConfig,
|
| 52 |
+
*,
|
| 53 |
+
profile: OpenAIChatProfile,
|
| 54 |
+
rate_limiter: ProviderRateLimiter,
|
| 55 |
+
api_key_provider: OpenAIAsyncCredentialProvider | None = None,
|
| 56 |
+
default_headers: Mapping[str, str] | None = None,
|
| 57 |
+
) -> None:
|
| 58 |
+
super().__init__(
|
| 59 |
+
config,
|
| 60 |
+
profile=profile,
|
| 61 |
+
rate_limiter=rate_limiter,
|
| 62 |
+
api_key_provider=api_key_provider,
|
| 63 |
+
default_headers=default_headers,
|
| 64 |
+
)
|
| 65 |
+
self._tool_call_extra_content_by_id: dict[str, dict[str, Any]] = {}
|
| 66 |
+
|
| 67 |
+
def _record_tool_call_extra_content(
|
| 68 |
+
self, tool_call_id: str, extra_content: dict[str, Any]
|
| 69 |
+
) -> None:
|
| 70 |
+
if (
|
| 71 |
+
tool_call_id not in self._tool_call_extra_content_by_id
|
| 72 |
+
and len(self._tool_call_extra_content_by_id)
|
| 73 |
+
>= _MAX_TOOL_CALL_EXTRA_CONTENT_CACHE
|
| 74 |
+
):
|
| 75 |
+
self._tool_call_extra_content_by_id.pop(
|
| 76 |
+
next(iter(self._tool_call_extra_content_by_id))
|
| 77 |
+
)
|
| 78 |
+
self._tool_call_extra_content_by_id[tool_call_id] = deepcopy(extra_content)
|
| 79 |
+
|
| 80 |
+
def _build_request_body(
|
| 81 |
+
self,
|
| 82 |
+
request: MessagesRequest,
|
| 83 |
+
*,
|
| 84 |
+
reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY,
|
| 85 |
+
) -> dict[str, Any]:
|
| 86 |
+
return build_openai_chat_request_body(
|
| 87 |
+
request,
|
| 88 |
+
reasoning=reasoning,
|
| 89 |
+
policy=self._profile.request_policy,
|
| 90 |
+
postprocessors=(
|
| 91 |
+
lambda body, request_data, policy: apply_google_request_quirks(
|
| 92 |
+
body,
|
| 93 |
+
request_data,
|
| 94 |
+
policy,
|
| 95 |
+
tool_call_extra_content_by_id=(self._tool_call_extra_content_by_id),
|
| 96 |
+
),
|
| 97 |
+
self._profile.apply_reasoning,
|
| 98 |
+
),
|
| 99 |
+
)
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Gemini
|
| 2 |
|
| 3 |
from copy import deepcopy
|
| 4 |
from typing import Any, cast
|
|
@@ -6,10 +6,10 @@ from typing import Any, cast
|
|
| 6 |
from free_claude_code.core.anthropic.models import MessagesRequest
|
| 7 |
from free_claude_code.core.reasoning import ReasoningPolicy
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
|
| 12 |
-
def
|
| 13 |
body: dict[str, Any],
|
| 14 |
request_data: MessagesRequest,
|
| 15 |
reasoning: ReasoningPolicy,
|
|
@@ -23,17 +23,31 @@ def apply_gemini_request_quirks(
|
|
| 23 |
extra_body.update(deepcopy(request_extra))
|
| 24 |
|
| 25 |
if reasoning.requests_reasoning:
|
| 26 |
-
|
| 27 |
|
| 28 |
if extra_body:
|
| 29 |
body["extra_body"] = extra_body
|
| 30 |
|
| 31 |
-
|
| 32 |
body,
|
| 33 |
tool_call_extra_content_by_id=tool_call_extra_content_by_id,
|
| 34 |
)
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]:
|
| 38 |
value = container.get(key)
|
| 39 |
if isinstance(value, dict):
|
|
@@ -43,15 +57,6 @@ def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]:
|
|
| 43 |
return nested
|
| 44 |
|
| 45 |
|
| 46 |
-
def _apply_thinking_config(extra_body: dict[str, Any]) -> None:
|
| 47 |
-
# OpenAI's SDK merges its ``extra_body`` argument into the request JSON.
|
| 48 |
-
# Google expects its extension fields under a literal JSON ``extra_body`` key.
|
| 49 |
-
literal_extra_body = _ensure_dict(extra_body, "extra_body")
|
| 50 |
-
google_section = _ensure_dict(literal_extra_body, "google")
|
| 51 |
-
thinking_cfg = _ensure_dict(google_section, "thinking_config")
|
| 52 |
-
thinking_cfg.setdefault("include_thoughts", True)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
def _thought_signature_from_extra_content(extra_content: Any) -> str | None:
|
| 56 |
if not isinstance(extra_content, dict):
|
| 57 |
return None
|
|
@@ -145,11 +150,11 @@ def _apply_missing_current_turn_signatures(messages: list[Any]) -> None:
|
|
| 145 |
if _tool_call_thought_signature(first_tool_call):
|
| 146 |
continue
|
| 147 |
_set_tool_call_thought_signature(
|
| 148 |
-
first_tool_call,
|
| 149 |
)
|
| 150 |
|
| 151 |
|
| 152 |
-
def
|
| 153 |
body: dict[str, Any],
|
| 154 |
*,
|
| 155 |
tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None,
|
|
|
|
| 1 |
+
"""Google Gemini extensions shared by Google OpenAI-compatible endpoints."""
|
| 2 |
|
| 3 |
from copy import deepcopy
|
| 4 |
from typing import Any, cast
|
|
|
|
| 6 |
from free_claude_code.core.anthropic.models import MessagesRequest
|
| 7 |
from free_claude_code.core.reasoning import ReasoningPolicy
|
| 8 |
|
| 9 |
+
GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
| 10 |
|
| 11 |
|
| 12 |
+
def apply_google_request_quirks(
|
| 13 |
body: dict[str, Any],
|
| 14 |
request_data: MessagesRequest,
|
| 15 |
reasoning: ReasoningPolicy,
|
|
|
|
| 23 |
extra_body.update(deepcopy(request_extra))
|
| 24 |
|
| 25 |
if reasoning.requests_reasoning:
|
| 26 |
+
_thinking_config(extra_body).setdefault("include_thoughts", True)
|
| 27 |
|
| 28 |
if extra_body:
|
| 29 |
body["extra_body"] = extra_body
|
| 30 |
|
| 31 |
+
_apply_google_tool_call_signatures(
|
| 32 |
body,
|
| 33 |
tool_call_extra_content_by_id=tool_call_extra_content_by_id,
|
| 34 |
)
|
| 35 |
|
| 36 |
|
| 37 |
+
def google_thinking_config(body: dict[str, Any]) -> dict[str, Any]:
|
| 38 |
+
"""Return Google's literal ``extra_body.google.thinking_config`` object."""
|
| 39 |
+
extra_body = _ensure_dict(body, "extra_body")
|
| 40 |
+
return _thinking_config(extra_body)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _thinking_config(extra_body: dict[str, Any]) -> dict[str, Any]:
|
| 44 |
+
# OpenAI's SDK merges its ``extra_body`` argument into the request JSON.
|
| 45 |
+
# Google expects its extension fields under a literal JSON ``extra_body`` key.
|
| 46 |
+
literal_extra_body = _ensure_dict(extra_body, "extra_body")
|
| 47 |
+
google_section = _ensure_dict(literal_extra_body, "google")
|
| 48 |
+
return _ensure_dict(google_section, "thinking_config")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]:
|
| 52 |
value = container.get(key)
|
| 53 |
if isinstance(value, dict):
|
|
|
|
| 57 |
return nested
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def _thought_signature_from_extra_content(extra_content: Any) -> str | None:
|
| 61 |
if not isinstance(extra_content, dict):
|
| 62 |
return None
|
|
|
|
| 150 |
if _tool_call_thought_signature(first_tool_call):
|
| 151 |
continue
|
| 152 |
_set_tool_call_thought_signature(
|
| 153 |
+
first_tool_call, GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
| 154 |
)
|
| 155 |
|
| 156 |
|
| 157 |
+
def _apply_google_tool_call_signatures(
|
| 158 |
body: dict[str, Any],
|
| 159 |
*,
|
| 160 |
tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None,
|
|
@@ -6,7 +6,7 @@ from free_claude_code.providers.rate_limit import ProviderRateLimiter
|
|
| 6 |
from .base_url import openai_v1_base_url
|
| 7 |
from .extra_body import validate_extra_body_does_not_override_canonical_fields
|
| 8 |
from .profiles import OPENAI_CHAT_PROFILES, OpenAIChatProfile
|
| 9 |
-
from .provider import OpenAIChatProvider
|
| 10 |
from .reasoning import (
|
| 11 |
NO_REASONING,
|
| 12 |
ChatTemplateReasoning,
|
|
@@ -41,6 +41,7 @@ __all__ = [
|
|
| 41 |
"OPENAI_CHAT_PROFILES",
|
| 42 |
"ChatTemplateReasoning",
|
| 43 |
"NamedEffortReasoning",
|
|
|
|
| 44 |
"OpenAIChatProfile",
|
| 45 |
"OpenAIChatProvider",
|
| 46 |
"OpenAIChatRequestPolicy",
|
|
|
|
| 6 |
from .base_url import openai_v1_base_url
|
| 7 |
from .extra_body import validate_extra_body_does_not_override_canonical_fields
|
| 8 |
from .profiles import OPENAI_CHAT_PROFILES, OpenAIChatProfile
|
| 9 |
+
from .provider import OpenAIAsyncCredentialProvider, OpenAIChatProvider
|
| 10 |
from .reasoning import (
|
| 11 |
NO_REASONING,
|
| 12 |
ChatTemplateReasoning,
|
|
|
|
| 41 |
"OPENAI_CHAT_PROFILES",
|
| 42 |
"ChatTemplateReasoning",
|
| 43 |
"NamedEffortReasoning",
|
| 44 |
+
"OpenAIAsyncCredentialProvider",
|
| 45 |
"OpenAIChatProfile",
|
| 46 |
"OpenAIChatProvider",
|
| 47 |
"OpenAIChatRequestPolicy",
|
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import asyncio
|
| 4 |
import sys
|
| 5 |
import uuid
|
| 6 |
-
from collections.abc import AsyncIterator, Iterator, Mapping
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import httpx
|
|
@@ -63,6 +63,8 @@ from .usage import (
|
|
| 63 |
usage_int,
|
| 64 |
)
|
| 65 |
|
|
|
|
|
|
|
| 66 |
|
| 67 |
class OpenAIChatProvider(BaseProvider):
|
| 68 |
"""OpenAI-compatible ``/chat/completions`` provider configured by a profile."""
|
|
@@ -74,6 +76,7 @@ class OpenAIChatProvider(BaseProvider):
|
|
| 74 |
profile: OpenAIChatProfile,
|
| 75 |
rate_limiter: ProviderRateLimiter,
|
| 76 |
default_headers: Mapping[str, str] | None = None,
|
|
|
|
| 77 |
):
|
| 78 |
super().__init__(config)
|
| 79 |
self._profile = profile
|
|
@@ -96,7 +99,7 @@ class OpenAIChatProvider(BaseProvider):
|
|
| 96 |
),
|
| 97 |
)
|
| 98 |
self._client = AsyncOpenAI(
|
| 99 |
-
api_key=self._api_key,
|
| 100 |
base_url=self._base_url,
|
| 101 |
max_retries=0,
|
| 102 |
default_headers=default_headers,
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import sys
|
| 5 |
import uuid
|
| 6 |
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import httpx
|
|
|
|
| 63 |
usage_int,
|
| 64 |
)
|
| 65 |
|
| 66 |
+
OpenAIAsyncCredentialProvider = Callable[[], Awaitable[str]]
|
| 67 |
+
|
| 68 |
|
| 69 |
class OpenAIChatProvider(BaseProvider):
|
| 70 |
"""OpenAI-compatible ``/chat/completions`` provider configured by a profile."""
|
|
|
|
| 76 |
profile: OpenAIChatProfile,
|
| 77 |
rate_limiter: ProviderRateLimiter,
|
| 78 |
default_headers: Mapping[str, str] | None = None,
|
| 79 |
+
api_key_provider: OpenAIAsyncCredentialProvider | None = None,
|
| 80 |
):
|
| 81 |
super().__init__(config)
|
| 82 |
self._profile = profile
|
|
|
|
| 99 |
),
|
| 100 |
)
|
| 101 |
self._client = AsyncOpenAI(
|
| 102 |
+
api_key=api_key_provider or self._api_key,
|
| 103 |
base_url=self._base_url,
|
| 104 |
max_retries=0,
|
| 105 |
default_headers=default_headers,
|
|
@@ -23,6 +23,16 @@ def provider_credential(descriptor: ProviderDescriptor, settings: Settings) -> s
|
|
| 23 |
return ""
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def require_provider_credential(
|
| 27 |
descriptor: ProviderDescriptor, credential: str
|
| 28 |
) -> None:
|
|
|
|
| 23 |
return ""
|
| 24 |
|
| 25 |
|
| 26 |
+
def has_provider_configuration(
|
| 27 |
+
descriptor: ProviderDescriptor, settings: Settings
|
| 28 |
+
) -> bool:
|
| 29 |
+
"""Return whether all provider-defining settings are present."""
|
| 30 |
+
attrs = descriptor.configuration_attrs()
|
| 31 |
+
if attrs:
|
| 32 |
+
return all(string_setting(settings, attr).strip() for attr in attrs)
|
| 33 |
+
return descriptor.static_credential is not None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
def require_provider_credential(
|
| 37 |
descriptor: ProviderDescriptor, credential: str
|
| 38 |
) -> None:
|
|
@@ -14,7 +14,7 @@ from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
|
|
| 14 |
from free_claude_code.config.settings import Settings
|
| 15 |
from free_claude_code.providers.base import BaseProvider
|
| 16 |
|
| 17 |
-
from .config import
|
| 18 |
from .model_cache import ProviderModelCache
|
| 19 |
from .validation import provider_query_failure_reason
|
| 20 |
|
|
@@ -31,11 +31,7 @@ def model_cache_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]
|
|
| 31 |
return tuple(
|
| 32 |
provider_id
|
| 33 |
for provider_id, descriptor in PROVIDER_CATALOG.items()
|
| 34 |
-
if descriptor
|
| 35 |
-
or (
|
| 36 |
-
descriptor.credential_env is not None
|
| 37 |
-
and provider_credential(descriptor, settings).strip()
|
| 38 |
-
)
|
| 39 |
)
|
| 40 |
|
| 41 |
|
|
|
|
| 14 |
from free_claude_code.config.settings import Settings
|
| 15 |
from free_claude_code.providers.base import BaseProvider
|
| 16 |
|
| 17 |
+
from .config import has_provider_configuration
|
| 18 |
from .model_cache import ProviderModelCache
|
| 19 |
from .validation import provider_query_failure_reason
|
| 20 |
|
|
|
|
| 31 |
return tuple(
|
| 32 |
provider_id
|
| 33 |
for provider_id, descriptor in PROVIDER_CATALOG.items()
|
| 34 |
+
if has_provider_configuration(descriptor, settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
)
|
| 36 |
|
| 37 |
|
|
@@ -97,6 +97,21 @@ def _create_gemini(
|
|
| 97 |
return GeminiProvider(config, rate_limiter=rate_limiter)
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def _create_github_models(
|
| 101 |
config: ProviderConfig,
|
| 102 |
_settings: Settings,
|
|
@@ -115,6 +130,7 @@ _SPECIAL_PROVIDER_FACTORIES: dict[str, ProviderFactory] = {
|
|
| 115 |
"lmstudio": _create_lmstudio,
|
| 116 |
"cloudflare": _create_cloudflare,
|
| 117 |
"gemini": _create_gemini,
|
|
|
|
| 118 |
"github_models": _create_github_models,
|
| 119 |
}
|
| 120 |
|
|
|
|
| 97 |
return GeminiProvider(config, rate_limiter=rate_limiter)
|
| 98 |
|
| 99 |
|
| 100 |
+
def _create_vertex(
|
| 101 |
+
config: ProviderConfig,
|
| 102 |
+
settings: Settings,
|
| 103 |
+
rate_limiter: ProviderRateLimiter,
|
| 104 |
+
) -> BaseProvider:
|
| 105 |
+
from free_claude_code.providers.vertex import VertexProvider
|
| 106 |
+
|
| 107 |
+
return VertexProvider(
|
| 108 |
+
config,
|
| 109 |
+
project_id=settings.vertex_project_id,
|
| 110 |
+
location=settings.vertex_location,
|
| 111 |
+
rate_limiter=rate_limiter,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
def _create_github_models(
|
| 116 |
config: ProviderConfig,
|
| 117 |
_settings: Settings,
|
|
|
|
| 130 |
"lmstudio": _create_lmstudio,
|
| 131 |
"cloudflare": _create_cloudflare,
|
| 132 |
"gemini": _create_gemini,
|
| 133 |
+
"vertex": _create_vertex,
|
| 134 |
"github_models": _create_github_models,
|
| 135 |
}
|
| 136 |
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Vertex AI OpenAI-compatible adapter."""
|
| 2 |
+
|
| 3 |
+
from .client import VertexProvider
|
| 4 |
+
|
| 5 |
+
__all__ = ["VertexProvider"]
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Renewable Google Application Default Credentials for Vertex AI."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from collections.abc import Callable
|
| 5 |
+
|
| 6 |
+
import google.auth
|
| 7 |
+
import requests
|
| 8 |
+
from google.auth.credentials import Credentials
|
| 9 |
+
from google.auth.exceptions import (
|
| 10 |
+
DefaultCredentialsError,
|
| 11 |
+
GoogleAuthError,
|
| 12 |
+
RefreshError,
|
| 13 |
+
TransportError,
|
| 14 |
+
)
|
| 15 |
+
from google.auth.transport.requests import Request
|
| 16 |
+
|
| 17 |
+
from free_claude_code.core.failures import ExecutionFailure, FailureKind
|
| 18 |
+
|
| 19 |
+
GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
| 20 |
+
|
| 21 |
+
CredentialsLoader = Callable[[], Credentials]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_application_default_credentials() -> Credentials:
|
| 25 |
+
"""Load ADC with the scope required by Vertex AI."""
|
| 26 |
+
credentials, _project = google.auth.default(scopes=(GOOGLE_CLOUD_PLATFORM_SCOPE,))
|
| 27 |
+
return credentials
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class GoogleAccessTokenProvider:
|
| 31 |
+
"""Return a valid ADC access token, refreshing it without blocking the event loop."""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
credentials_loader: CredentialsLoader = load_application_default_credentials,
|
| 36 |
+
*,
|
| 37 |
+
proxy: str = "",
|
| 38 |
+
) -> None:
|
| 39 |
+
self._credentials_loader = credentials_loader
|
| 40 |
+
self._proxy = proxy
|
| 41 |
+
self._credentials: Credentials | None = None
|
| 42 |
+
self._refresh_lock = asyncio.Lock()
|
| 43 |
+
|
| 44 |
+
async def __call__(self) -> str:
|
| 45 |
+
credentials = self._credentials
|
| 46 |
+
if credentials is not None and credentials.valid and credentials.token:
|
| 47 |
+
return credentials.token
|
| 48 |
+
|
| 49 |
+
async with self._refresh_lock:
|
| 50 |
+
credentials = self._credentials
|
| 51 |
+
if credentials is not None and credentials.valid and credentials.token:
|
| 52 |
+
return credentials.token
|
| 53 |
+
try:
|
| 54 |
+
if credentials is None:
|
| 55 |
+
credentials = await asyncio.to_thread(self._credentials_loader)
|
| 56 |
+
self._credentials = credentials
|
| 57 |
+
if not credentials.valid or not credentials.token:
|
| 58 |
+
await asyncio.to_thread(self._refresh, credentials)
|
| 59 |
+
token = credentials.token
|
| 60 |
+
if not isinstance(token, str) or not token:
|
| 61 |
+
raise RefreshError("Google credentials returned no access token.")
|
| 62 |
+
return token
|
| 63 |
+
except ExecutionFailure:
|
| 64 |
+
raise
|
| 65 |
+
except GoogleAuthError as exc:
|
| 66 |
+
raise _google_auth_failure(exc) from exc
|
| 67 |
+
|
| 68 |
+
def _refresh(self, credentials: Credentials) -> None:
|
| 69 |
+
with requests.Session() as session:
|
| 70 |
+
if self._proxy:
|
| 71 |
+
session.proxies.update({"http": self._proxy, "https": self._proxy})
|
| 72 |
+
credentials.refresh(Request(session=session))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _google_auth_failure(exc: GoogleAuthError) -> ExecutionFailure:
|
| 76 |
+
if isinstance(exc, TransportError) or (
|
| 77 |
+
isinstance(exc, RefreshError) and bool(getattr(exc, "retryable", False))
|
| 78 |
+
):
|
| 79 |
+
return ExecutionFailure(
|
| 80 |
+
kind=FailureKind.UNAVAILABLE,
|
| 81 |
+
status_code=503,
|
| 82 |
+
message=(
|
| 83 |
+
"Google authentication is temporarily unavailable while refreshing "
|
| 84 |
+
"Application Default Credentials."
|
| 85 |
+
),
|
| 86 |
+
retryable=True,
|
| 87 |
+
)
|
| 88 |
+
if isinstance(exc, DefaultCredentialsError):
|
| 89 |
+
message = (
|
| 90 |
+
"Google Application Default Credentials were not found. Run "
|
| 91 |
+
"`gcloud auth application-default login`, set "
|
| 92 |
+
"GOOGLE_APPLICATION_CREDENTIALS, or attach a service account."
|
| 93 |
+
)
|
| 94 |
+
else:
|
| 95 |
+
message = (
|
| 96 |
+
"Google Application Default Credentials could not be refreshed. "
|
| 97 |
+
"Reauthenticate with `gcloud auth application-default login` or check "
|
| 98 |
+
"the configured service account."
|
| 99 |
+
)
|
| 100 |
+
return ExecutionFailure(
|
| 101 |
+
kind=FailureKind.AUTHENTICATION,
|
| 102 |
+
status_code=401,
|
| 103 |
+
message=message,
|
| 104 |
+
retryable=False,
|
| 105 |
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Vertex AI provider using the OpenAI-compatible Chat Completions API."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import replace
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from free_claude_code.core.anthropic import ReasoningReplayMode
|
| 9 |
+
from free_claude_code.providers.base import ProviderConfig
|
| 10 |
+
from free_claude_code.providers.google_openai import (
|
| 11 |
+
GoogleOpenAIProvider,
|
| 12 |
+
GoogleThinkingBudgetReasoning,
|
| 13 |
+
)
|
| 14 |
+
from free_claude_code.providers.http import maybe_await_aclose
|
| 15 |
+
from free_claude_code.providers.model_listing import ModelListResponseError
|
| 16 |
+
from free_claude_code.providers.openai_chat import (
|
| 17 |
+
OpenAIChatProfile,
|
| 18 |
+
OpenAIChatRequestPolicy,
|
| 19 |
+
)
|
| 20 |
+
from free_claude_code.providers.rate_limit import ProviderRateLimiter
|
| 21 |
+
|
| 22 |
+
from .auth import GoogleAccessTokenProvider
|
| 23 |
+
from .endpoint import vertex_openai_base_url, vertex_publisher_models_url
|
| 24 |
+
from .models import extract_vertex_model_page
|
| 25 |
+
|
| 26 |
+
_REQUEST_POLICY = OpenAIChatRequestPolicy(
|
| 27 |
+
provider_name="VERTEX",
|
| 28 |
+
reasoning_replay=ReasoningReplayMode.REASONING_CONTENT,
|
| 29 |
+
)
|
| 30 |
+
_PROFILE = OpenAIChatProfile(_REQUEST_POLICY, GoogleThinkingBudgetReasoning())
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class VertexProvider(GoogleOpenAIProvider):
|
| 34 |
+
"""Vertex AI Gemini models with renewable ADC and native model discovery."""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
config: ProviderConfig,
|
| 39 |
+
*,
|
| 40 |
+
project_id: str,
|
| 41 |
+
location: str,
|
| 42 |
+
rate_limiter: ProviderRateLimiter,
|
| 43 |
+
access_token_provider: GoogleAccessTokenProvider | None = None,
|
| 44 |
+
) -> None:
|
| 45 |
+
self._project_id = project_id.strip()
|
| 46 |
+
self._location = location.strip().lower()
|
| 47 |
+
base_url = vertex_openai_base_url(self._project_id, self._location)
|
| 48 |
+
self._models_url = vertex_publisher_models_url(self._location)
|
| 49 |
+
self._access_token_provider = (
|
| 50 |
+
access_token_provider or GoogleAccessTokenProvider(proxy=config.proxy)
|
| 51 |
+
)
|
| 52 |
+
self._model_list_client = httpx.AsyncClient(
|
| 53 |
+
proxy=config.proxy or None,
|
| 54 |
+
timeout=httpx.Timeout(
|
| 55 |
+
config.http_read_timeout,
|
| 56 |
+
connect=config.http_connect_timeout,
|
| 57 |
+
read=config.http_read_timeout,
|
| 58 |
+
write=config.http_write_timeout,
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
super().__init__(
|
| 62 |
+
replace(config, base_url=base_url),
|
| 63 |
+
profile=_PROFILE,
|
| 64 |
+
rate_limiter=rate_limiter,
|
| 65 |
+
api_key_provider=self._access_token_provider,
|
| 66 |
+
default_headers={"x-goog-user-project": self._project_id},
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
async def cleanup(self) -> None:
|
| 70 |
+
"""Release both OpenAI-compatible and native model-list clients."""
|
| 71 |
+
try:
|
| 72 |
+
await super().cleanup()
|
| 73 |
+
finally:
|
| 74 |
+
await self._model_list_client.aclose()
|
| 75 |
+
|
| 76 |
+
async def list_model_ids(self) -> frozenset[str]:
|
| 77 |
+
"""List Vertex publisher models and translate their resource names."""
|
| 78 |
+
model_ids: set[str] = set()
|
| 79 |
+
page_token: str | None = None
|
| 80 |
+
seen_page_tokens: set[str] = set()
|
| 81 |
+
while True:
|
| 82 |
+
payload = await self._list_model_page(page_token)
|
| 83 |
+
page_ids, page_token = extract_vertex_model_page(payload)
|
| 84 |
+
model_ids.update(page_ids)
|
| 85 |
+
if page_token is None:
|
| 86 |
+
break
|
| 87 |
+
if page_token in seen_page_tokens:
|
| 88 |
+
raise ModelListResponseError(
|
| 89 |
+
"VERTEX model-list response is malformed: repeated nextPageToken"
|
| 90 |
+
)
|
| 91 |
+
seen_page_tokens.add(page_token)
|
| 92 |
+
if not model_ids:
|
| 93 |
+
raise ModelListResponseError(
|
| 94 |
+
"VERTEX model-list response is malformed: response did not include "
|
| 95 |
+
"any model ids"
|
| 96 |
+
)
|
| 97 |
+
return frozenset(model_ids)
|
| 98 |
+
|
| 99 |
+
async def _list_model_page(self, page_token: str | None) -> Any:
|
| 100 |
+
async def request() -> httpx.Response:
|
| 101 |
+
token = await self._access_token_provider()
|
| 102 |
+
response = await self._model_list_client.get(
|
| 103 |
+
self._models_url,
|
| 104 |
+
params={"pageToken": page_token} if page_token else None,
|
| 105 |
+
headers={
|
| 106 |
+
"Authorization": f"Bearer {token}",
|
| 107 |
+
"x-goog-user-project": self._project_id,
|
| 108 |
+
},
|
| 109 |
+
)
|
| 110 |
+
try:
|
| 111 |
+
response.raise_for_status()
|
| 112 |
+
except Exception:
|
| 113 |
+
await maybe_await_aclose(response)
|
| 114 |
+
raise
|
| 115 |
+
return response
|
| 116 |
+
|
| 117 |
+
response = await self._rate_limiter.execute_with_retry(request)
|
| 118 |
+
try:
|
| 119 |
+
try:
|
| 120 |
+
return response.json()
|
| 121 |
+
except ValueError as exc:
|
| 122 |
+
raise ModelListResponseError(
|
| 123 |
+
"VERTEX model-list response is malformed: invalid JSON"
|
| 124 |
+
) from exc
|
| 125 |
+
finally:
|
| 126 |
+
await maybe_await_aclose(response)
|
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vertex AI service and OpenAI-compatible endpoint construction."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from urllib.parse import quote
|
| 5 |
+
|
| 6 |
+
from free_claude_code.application.errors import ApplicationUnavailableError
|
| 7 |
+
from free_claude_code.config.provider_catalog import VERTEX_AI_API_ROOT
|
| 8 |
+
|
| 9 |
+
_LOCATION_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def vertex_service_endpoint(location: str) -> str:
|
| 13 |
+
"""Return Google's global or regional Vertex AI service endpoint."""
|
| 14 |
+
normalized = _validated_location(location)
|
| 15 |
+
if normalized == "global":
|
| 16 |
+
return VERTEX_AI_API_ROOT
|
| 17 |
+
return f"https://{normalized}-aiplatform.googleapis.com"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def vertex_openai_base_url(project_id: str, location: str) -> str:
|
| 21 |
+
"""Return the project-scoped Vertex OpenAI-compatible API base URL."""
|
| 22 |
+
project = project_id.strip()
|
| 23 |
+
if not project:
|
| 24 |
+
raise ApplicationUnavailableError(
|
| 25 |
+
"VERTEX_PROJECT_ID is not set. Add it to your .env file."
|
| 26 |
+
)
|
| 27 |
+
normalized_location = _validated_location(location)
|
| 28 |
+
service_endpoint = vertex_service_endpoint(normalized_location)
|
| 29 |
+
return (
|
| 30 |
+
f"{service_endpoint}/v1/projects/{quote(project, safe='')}/locations/"
|
| 31 |
+
f"{normalized_location}/endpoints/openapi"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def vertex_publisher_models_url(location: str) -> str:
|
| 36 |
+
"""Return Google's native publisher-model listing endpoint."""
|
| 37 |
+
return f"{vertex_service_endpoint(location)}/v1beta1/publishers/google/models"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _validated_location(location: str) -> str:
|
| 41 |
+
normalized = location.strip().lower()
|
| 42 |
+
if not normalized:
|
| 43 |
+
raise ApplicationUnavailableError(
|
| 44 |
+
"VERTEX_LOCATION is not set. Use global or a Google Cloud region."
|
| 45 |
+
)
|
| 46 |
+
if _LOCATION_PATTERN.fullmatch(normalized) is None:
|
| 47 |
+
raise ApplicationUnavailableError(
|
| 48 |
+
"VERTEX_LOCATION must be global or a lowercase Google Cloud region."
|
| 49 |
+
)
|
| 50 |
+
return normalized
|
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vertex publisher-model response parsing."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Mapping, Sequence
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from free_claude_code.providers.model_listing import ModelListResponseError
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def extract_vertex_model_page(payload: Any) -> tuple[frozenset[str], str | None]:
|
| 10 |
+
"""Translate one Google publisher-model page to OpenAI-compatible model IDs."""
|
| 11 |
+
if not isinstance(payload, Mapping):
|
| 12 |
+
raise _malformed("expected an object")
|
| 13 |
+
models = payload.get("publisherModels")
|
| 14 |
+
if not _is_sequence(models):
|
| 15 |
+
raise _malformed("expected top-level publisherModels array")
|
| 16 |
+
|
| 17 |
+
model_ids: set[str] = set()
|
| 18 |
+
for item in models:
|
| 19 |
+
if not isinstance(item, Mapping):
|
| 20 |
+
raise _malformed("expected every publisherModels item to be an object")
|
| 21 |
+
name = item.get("name")
|
| 22 |
+
if not isinstance(name, str):
|
| 23 |
+
raise _malformed("expected every publisher model to include name")
|
| 24 |
+
model_ids.add(_openai_model_id(name))
|
| 25 |
+
|
| 26 |
+
next_page_token = payload.get("nextPageToken")
|
| 27 |
+
if next_page_token is not None and not isinstance(next_page_token, str):
|
| 28 |
+
raise _malformed("expected nextPageToken to be a string")
|
| 29 |
+
return frozenset(model_ids), next_page_token or None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _openai_model_id(resource_name: str) -> str:
|
| 33 |
+
parts = resource_name.split("/", 3)
|
| 34 |
+
if (
|
| 35 |
+
len(parts) != 4
|
| 36 |
+
or parts[0] != "publishers"
|
| 37 |
+
or not parts[1].strip()
|
| 38 |
+
or parts[2] != "models"
|
| 39 |
+
or not parts[3].strip()
|
| 40 |
+
):
|
| 41 |
+
raise _malformed("expected publisher model resource names")
|
| 42 |
+
return f"{parts[1]}/{parts[3]}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _is_sequence(value: Any) -> bool:
|
| 46 |
+
return isinstance(value, Sequence) and not isinstance(
|
| 47 |
+
value, str | bytes | bytearray
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _malformed(reason: str) -> ModelListResponseError:
|
| 52 |
+
return ModelListResponseError(f"VERTEX model-list response is malformed: {reason}")
|
|
@@ -152,6 +152,15 @@ def test_admin_api_fetches_bypass_browser_cache():
|
|
| 152 |
assert 'cache: "no-store"' in script
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def test_admin_page_no_longer_renders_generated_env_panel(monkeypatch, tmp_path):
|
| 156 |
_set_home(monkeypatch, tmp_path)
|
| 157 |
app = create_test_app()
|
|
|
|
| 152 |
assert 'cache: "no-store"' in script
|
| 153 |
|
| 154 |
|
| 155 |
+
def test_admin_provider_cards_support_non_key_configuration():
|
| 156 |
+
script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
|
| 157 |
+
encoding="utf-8"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
assert '"missing_config"' in script
|
| 161 |
+
assert ": provider.configuration;" in script
|
| 162 |
+
|
| 163 |
+
|
| 164 |
def test_admin_page_no_longer_renders_generated_env_panel(monkeypatch, tmp_path):
|
| 165 |
_set_home(monkeypatch, tmp_path)
|
| 166 |
app = create_test_app()
|
|
@@ -39,6 +39,7 @@ class TestSettings:
|
|
| 39 |
|
| 40 |
monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False)
|
| 41 |
monkeypatch.delenv("MODEL", raising=False)
|
|
|
|
| 42 |
monkeypatch.delenv("HTTP_READ_TIMEOUT", raising=False)
|
| 43 |
monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False)
|
| 44 |
monkeypatch.setitem(Settings.model_config, "env_file", ())
|
|
@@ -58,6 +59,7 @@ class TestSettings:
|
|
| 58 |
assert settings.debug_subagent_stack is False
|
| 59 |
assert settings.log_level == "INFO"
|
| 60 |
assert settings.open_admin_browser is True
|
|
|
|
| 61 |
|
| 62 |
def test_open_admin_browser_loads_from_environment(self, monkeypatch):
|
| 63 |
from free_claude_code.config.settings import Settings
|
|
@@ -337,6 +339,18 @@ class TestSettings:
|
|
| 337 |
assert settings.cloudflare_account_id == "cf-account"
|
| 338 |
assert settings.cloudflare_proxy == "http://proxy.test:8080"
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
def test_vercel_settings_from_env(self, monkeypatch):
|
| 341 |
"""Vercel AI Gateway key and proxy env vars load into settings."""
|
| 342 |
from free_claude_code.config.settings import Settings
|
|
|
|
| 39 |
|
| 40 |
monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False)
|
| 41 |
monkeypatch.delenv("MODEL", raising=False)
|
| 42 |
+
monkeypatch.delenv("VERTEX_LOCATION", raising=False)
|
| 43 |
monkeypatch.delenv("HTTP_READ_TIMEOUT", raising=False)
|
| 44 |
monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False)
|
| 45 |
monkeypatch.setitem(Settings.model_config, "env_file", ())
|
|
|
|
| 59 |
assert settings.debug_subagent_stack is False
|
| 60 |
assert settings.log_level == "INFO"
|
| 61 |
assert settings.open_admin_browser is True
|
| 62 |
+
assert settings.vertex_location == "global"
|
| 63 |
|
| 64 |
def test_open_admin_browser_loads_from_environment(self, monkeypatch):
|
| 65 |
from free_claude_code.config.settings import Settings
|
|
|
|
| 339 |
assert settings.cloudflare_account_id == "cf-account"
|
| 340 |
assert settings.cloudflare_proxy == "http://proxy.test:8080"
|
| 341 |
|
| 342 |
+
def test_vertex_settings_from_env(self, monkeypatch):
|
| 343 |
+
"""Vertex project, location, and proxy env vars load into settings."""
|
| 344 |
+
from free_claude_code.config.settings import Settings
|
| 345 |
+
|
| 346 |
+
monkeypatch.setenv("VERTEX_PROJECT_ID", "vertex-project")
|
| 347 |
+
monkeypatch.setenv("VERTEX_LOCATION", "us-central1")
|
| 348 |
+
monkeypatch.setenv("VERTEX_PROXY", "http://proxy.test:8080")
|
| 349 |
+
settings = Settings()
|
| 350 |
+
assert settings.vertex_project_id == "vertex-project"
|
| 351 |
+
assert settings.vertex_location == "us-central1"
|
| 352 |
+
assert settings.vertex_proxy == "http://proxy.test:8080"
|
| 353 |
+
|
| 354 |
def test_vercel_settings_from_env(self, monkeypatch):
|
| 355 |
"""Vercel AI Gateway key and proxy env vars load into settings."""
|
| 356 |
from free_claude_code.config.settings import Settings
|
|
@@ -42,3 +42,18 @@ def test_ollama_cloud_is_remote_and_distinct_from_local_ollama() -> None:
|
|
| 42 |
assert cloud.credential_env == "OLLAMA_API_KEY"
|
| 43 |
assert local.local is True
|
| 44 |
assert local.credential_env is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
assert cloud.credential_env == "OLLAMA_API_KEY"
|
| 43 |
assert local.local is True
|
| 44 |
assert local.credential_env is None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_provider_configuration_attrs_cover_multi_field_and_adc_providers() -> None:
|
| 48 |
+
assert PROVIDER_CATALOG["cloudflare"].configuration_attrs() == (
|
| 49 |
+
"cloudflare_api_token",
|
| 50 |
+
"cloudflare_account_id",
|
| 51 |
+
)
|
| 52 |
+
assert PROVIDER_CATALOG["vertex"].configuration_attrs() == ("vertex_project_id",)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_every_provider_declares_its_configuration_boundary() -> None:
|
| 56 |
+
assert all(
|
| 57 |
+
descriptor.configuration_attrs() or descriptor.static_credential is not None
|
| 58 |
+
for descriptor in PROVIDER_CATALOG.values()
|
| 59 |
+
)
|
|
@@ -117,3 +117,32 @@ def test_cloudflare_account_id_is_admin_provider_field() -> None:
|
|
| 117 |
assert entry.settings_attr == "cloudflare_account_id"
|
| 118 |
assert entry.section_id == "providers"
|
| 119 |
assert entry.secret is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
assert entry.settings_attr == "cloudflare_account_id"
|
| 118 |
assert entry.section_id == "providers"
|
| 119 |
assert entry.secret is False
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_vertex_project_and_location_are_admin_provider_fields() -> None:
|
| 123 |
+
project = FIELD_BY_KEY["VERTEX_PROJECT_ID"]
|
| 124 |
+
location = FIELD_BY_KEY["VERTEX_LOCATION"]
|
| 125 |
+
|
| 126 |
+
assert project.settings_attr == "vertex_project_id"
|
| 127 |
+
assert project.section_id == "providers"
|
| 128 |
+
assert project.secret is False
|
| 129 |
+
assert location.settings_attr == "vertex_location"
|
| 130 |
+
assert location.default == "global"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_vertex_admin_status_uses_project_configuration_not_an_api_key() -> None:
|
| 134 |
+
from free_claude_code.config.admin.status import provider_config_status
|
| 135 |
+
|
| 136 |
+
def vertex_status(project_id: str) -> dict[str, object]:
|
| 137 |
+
statuses = provider_config_status(
|
| 138 |
+
{
|
| 139 |
+
"VERTEX_PROJECT_ID": {"value": project_id},
|
| 140 |
+
"VERTEX_LOCATION": {"value": "global"},
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
return next(status for status in statuses if status["provider_id"] == "vertex")
|
| 144 |
+
|
| 145 |
+
assert vertex_status("")["status"] == "missing_config"
|
| 146 |
+
assert vertex_status("")["label"] == "Missing configuration"
|
| 147 |
+
assert vertex_status("")["configuration"] == "VERTEX_PROJECT_ID"
|
| 148 |
+
assert vertex_status("vertex-project")["status"] == "configured"
|
|
@@ -16,6 +16,7 @@ from free_claude_code.providers.openai_chat import (
|
|
| 16 |
OPENAI_CHAT_PROFILES,
|
| 17 |
OpenAIChatProvider,
|
| 18 |
)
|
|
|
|
| 19 |
from smoke.features import FEATURE_INVENTORY, README_FEATURES, feature_ids
|
| 20 |
|
| 21 |
VALID_SOURCE = {"readme", "public_surface"}
|
|
@@ -98,6 +99,7 @@ def test_provider_and_platform_registries_include_advertised_builtins() -> None:
|
|
| 98 |
"lmstudio": LMStudioProvider,
|
| 99 |
"github_models": GitHubModelsProvider,
|
| 100 |
"gemini": GeminiProvider,
|
|
|
|
| 101 |
}
|
| 102 |
assert set(OPENAI_CHAT_PROFILES).isdisjoint(specialized_provider_classes)
|
| 103 |
assert set(PROVIDER_CATALOG) == (
|
|
|
|
| 16 |
OPENAI_CHAT_PROFILES,
|
| 17 |
OpenAIChatProvider,
|
| 18 |
)
|
| 19 |
+
from free_claude_code.providers.vertex import VertexProvider
|
| 20 |
from smoke.features import FEATURE_INVENTORY, README_FEATURES, feature_ids
|
| 21 |
|
| 22 |
VALID_SOURCE = {"readme", "public_surface"}
|
|
|
|
| 99 |
"lmstudio": LMStudioProvider,
|
| 100 |
"github_models": GitHubModelsProvider,
|
| 101 |
"gemini": GeminiProvider,
|
| 102 |
+
"vertex": VertexProvider,
|
| 103 |
}
|
| 104 |
assert set(OPENAI_CHAT_PROFILES).isdisjoint(specialized_provider_classes)
|
| 105 |
assert set(PROVIDER_CATALOG) == (
|
|
@@ -9,6 +9,7 @@ _EXPECTED_PROVIDER_ORDER: tuple[str, ...] = (
|
|
| 9 |
"nvidia_nim",
|
| 10 |
"open_router",
|
| 11 |
"gemini",
|
|
|
|
| 12 |
"deepseek",
|
| 13 |
"mistral",
|
| 14 |
"mistral_codestral",
|
|
|
|
| 9 |
"nvidia_nim",
|
| 10 |
"open_router",
|
| 11 |
"gemini",
|
| 12 |
+
"vertex",
|
| 13 |
"deepseek",
|
| 14 |
"mistral",
|
| 15 |
"mistral_codestral",
|
|
@@ -46,6 +46,8 @@ def _settings(**overrides):
|
|
| 46 |
"github_models_token": "",
|
| 47 |
"zai_api_key": "",
|
| 48 |
"gemini_api_key": "",
|
|
|
|
|
|
|
| 49 |
"groq_api_key": "",
|
| 50 |
"sambanova_api_key": "",
|
| 51 |
"cerebras_api_key": "",
|
|
@@ -174,6 +176,23 @@ def test_bedrock_provider_configuration_uses_official_api_key(monkeypatch) -> No
|
|
| 174 |
assert models[0].source == "provider_default"
|
| 175 |
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
def test_wafer_provider_configuration_uses_api_key(monkeypatch) -> None:
|
| 178 |
monkeypatch.delenv("FCC_SMOKE_MODEL_WAFER", raising=False)
|
| 179 |
config = _smoke_config(
|
|
|
|
| 46 |
"github_models_token": "",
|
| 47 |
"zai_api_key": "",
|
| 48 |
"gemini_api_key": "",
|
| 49 |
+
"vertex_project_id": "",
|
| 50 |
+
"vertex_location": "global",
|
| 51 |
"groq_api_key": "",
|
| 52 |
"sambanova_api_key": "",
|
| 53 |
"cerebras_api_key": "",
|
|
|
|
| 176 |
assert models[0].source == "provider_default"
|
| 177 |
|
| 178 |
|
| 179 |
+
def test_vertex_provider_configuration_uses_project_id(monkeypatch) -> None:
|
| 180 |
+
monkeypatch.delenv("FCC_SMOKE_MODEL_VERTEX", raising=False)
|
| 181 |
+
config = _smoke_config(
|
| 182 |
+
settings=_settings(
|
| 183 |
+
model="ollama/llama3.1",
|
| 184 |
+
ollama_base_url="",
|
| 185 |
+
vertex_project_id="vertex-project",
|
| 186 |
+
)
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
assert config.has_provider_configuration("vertex")
|
| 190 |
+
models = config.provider_smoke_models()
|
| 191 |
+
assert [model.provider for model in models] == ["vertex"]
|
| 192 |
+
assert models[0].full_model == "vertex/google/gemini-3.5-flash"
|
| 193 |
+
assert models[0].source == "provider_default"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
def test_wafer_provider_configuration_uses_api_key(monkeypatch) -> None:
|
| 197 |
monkeypatch.delenv("FCC_SMOKE_MODEL_WAFER", raising=False)
|
| 198 |
config = _smoke_config(
|
|
@@ -7,8 +7,8 @@ import pytest
|
|
| 7 |
from free_claude_code.config.provider_catalog import GEMINI_DEFAULT_BASE
|
| 8 |
from free_claude_code.providers.base import ProviderConfig
|
| 9 |
from free_claude_code.providers.gemini import GeminiProvider
|
| 10 |
-
from free_claude_code.providers.
|
| 11 |
-
|
| 12 |
)
|
| 13 |
from tests.providers.request_factory import make_messages_request
|
| 14 |
from tests.providers.support import passthrough_rate_limiter, reasoning_for
|
|
@@ -291,7 +291,7 @@ def test_build_request_body_adds_current_turn_fallback_signature(
|
|
| 291 |
|
| 292 |
tool_calls = body["messages"][1]["tool_calls"]
|
| 293 |
assert tool_calls[0]["extra_content"] == {
|
| 294 |
-
"google": {"thought_signature":
|
| 295 |
}
|
| 296 |
assert "extra_content" not in tool_calls[1]
|
| 297 |
|
|
|
|
| 7 |
from free_claude_code.config.provider_catalog import GEMINI_DEFAULT_BASE
|
| 8 |
from free_claude_code.providers.base import ProviderConfig
|
| 9 |
from free_claude_code.providers.gemini import GeminiProvider
|
| 10 |
+
from free_claude_code.providers.google_openai import (
|
| 11 |
+
GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR,
|
| 12 |
)
|
| 13 |
from tests.providers.request_factory import make_messages_request
|
| 14 |
from tests.providers.support import passthrough_rate_limiter, reasoning_for
|
|
|
|
| 291 |
|
| 292 |
tool_calls = body["messages"][1]["tool_calls"]
|
| 293 |
assert tool_calls[0]["extra_content"] == {
|
| 294 |
+
"google": {"thought_signature": GOOGLE_SKIP_THOUGHT_SIGNATURE_VALIDATOR}
|
| 295 |
}
|
| 296 |
assert "extra_content" not in tool_calls[1]
|
| 297 |
|
|
@@ -42,6 +42,7 @@ def _settings(
|
|
| 42 |
wafer_api_key: str = "",
|
| 43 |
opencode_api_key: str = "",
|
| 44 |
zai_api_key: str = "",
|
|
|
|
| 45 |
) -> Settings:
|
| 46 |
return Settings.model_construct(
|
| 47 |
model=model,
|
|
@@ -55,6 +56,7 @@ def _settings(
|
|
| 55 |
wafer_api_key=wafer_api_key,
|
| 56 |
opencode_api_key=opencode_api_key,
|
| 57 |
zai_api_key=zai_api_key,
|
|
|
|
| 58 |
log_api_error_tracebacks=False,
|
| 59 |
)
|
| 60 |
|
|
@@ -486,6 +488,28 @@ async def test_runtime_refresh_model_list_cache_uses_configured_remote_keys_and_
|
|
| 486 |
assert result.failed_provider_ids == ()
|
| 487 |
|
| 488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
@pytest.mark.asyncio
|
| 490 |
async def test_runtime_refresh_model_list_cache_keeps_prior_cache_on_failure() -> None:
|
| 491 |
settings = _settings(
|
|
|
|
| 42 |
wafer_api_key: str = "",
|
| 43 |
opencode_api_key: str = "",
|
| 44 |
zai_api_key: str = "",
|
| 45 |
+
vertex_project_id: str = "",
|
| 46 |
) -> Settings:
|
| 47 |
return Settings.model_construct(
|
| 48 |
model=model,
|
|
|
|
| 56 |
wafer_api_key=wafer_api_key,
|
| 57 |
opencode_api_key=opencode_api_key,
|
| 58 |
zai_api_key=zai_api_key,
|
| 59 |
+
vertex_project_id=vertex_project_id,
|
| 60 |
log_api_error_tracebacks=False,
|
| 61 |
)
|
| 62 |
|
|
|
|
| 488 |
assert result.failed_provider_ids == ()
|
| 489 |
|
| 490 |
|
| 491 |
+
@pytest.mark.asyncio
|
| 492 |
+
async def test_runtime_refresh_model_list_cache_treats_vertex_project_as_configuration() -> (
|
| 493 |
+
None
|
| 494 |
+
):
|
| 495 |
+
settings = _settings(
|
| 496 |
+
model="nvidia_nim/nim-model",
|
| 497 |
+
vertex_project_id="vertex-project",
|
| 498 |
+
)
|
| 499 |
+
runtime = _manager(
|
| 500 |
+
settings,
|
| 501 |
+
{"vertex": FakeProvider(frozenset({"google/gemini-3.5-flash"}))},
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
result = await runtime.refresh_model_list_cache()
|
| 505 |
+
|
| 506 |
+
assert runtime.cached_model_ids() == {
|
| 507 |
+
"vertex": frozenset({"google/gemini-3.5-flash"})
|
| 508 |
+
}
|
| 509 |
+
assert result.refreshed_provider_ids == ("vertex",)
|
| 510 |
+
assert result.failed_provider_ids == ()
|
| 511 |
+
|
| 512 |
+
|
| 513 |
@pytest.mark.asyncio
|
| 514 |
async def test_runtime_refresh_model_list_cache_keeps_prior_cache_on_failure() -> None:
|
| 515 |
settings = _settings(
|
|
@@ -38,6 +38,7 @@ from free_claude_code.providers.runtime import (
|
|
| 38 |
build_provider_config,
|
| 39 |
create_provider,
|
| 40 |
)
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def _make_settings(**overrides):
|
|
@@ -93,6 +94,9 @@ def _make_settings(**overrides):
|
|
| 93 |
mock.cloudflare_proxy = ""
|
| 94 |
mock.gemini_api_key = ""
|
| 95 |
mock.gemini_proxy = ""
|
|
|
|
|
|
|
|
|
|
| 96 |
mock.groq_api_key = ""
|
| 97 |
mock.groq_proxy = ""
|
| 98 |
mock.cerebras_api_key = ""
|
|
@@ -389,6 +393,7 @@ def test_create_provider_uses_openai_chat_openrouter_by_default():
|
|
| 389 |
def test_create_provider_instantiates_each_builtin():
|
| 390 |
settings = _make_settings(
|
| 391 |
gemini_api_key="test_gemini_key",
|
|
|
|
| 392 |
groq_api_key="test_groq_key",
|
| 393 |
cerebras_api_key="test_cerebras_key",
|
| 394 |
fireworks_api_key="test_fireworks_key",
|
|
@@ -431,6 +436,7 @@ def test_create_provider_instantiates_each_builtin():
|
|
| 431 |
"github_models": GitHubModelsProvider,
|
| 432 |
"zai": OpenAIChatProvider,
|
| 433 |
"gemini": GeminiProvider,
|
|
|
|
| 434 |
"groq": OpenAIChatProvider,
|
| 435 |
"sambanova": OpenAIChatProvider,
|
| 436 |
"cerebras": OpenAIChatProvider,
|
|
|
|
| 38 |
build_provider_config,
|
| 39 |
create_provider,
|
| 40 |
)
|
| 41 |
+
from free_claude_code.providers.vertex import VertexProvider
|
| 42 |
|
| 43 |
|
| 44 |
def _make_settings(**overrides):
|
|
|
|
| 94 |
mock.cloudflare_proxy = ""
|
| 95 |
mock.gemini_api_key = ""
|
| 96 |
mock.gemini_proxy = ""
|
| 97 |
+
mock.vertex_project_id = "test-vertex-project"
|
| 98 |
+
mock.vertex_location = "global"
|
| 99 |
+
mock.vertex_proxy = ""
|
| 100 |
mock.groq_api_key = ""
|
| 101 |
mock.groq_proxy = ""
|
| 102 |
mock.cerebras_api_key = ""
|
|
|
|
| 393 |
def test_create_provider_instantiates_each_builtin():
|
| 394 |
settings = _make_settings(
|
| 395 |
gemini_api_key="test_gemini_key",
|
| 396 |
+
vertex_project_id="test-vertex-project",
|
| 397 |
groq_api_key="test_groq_key",
|
| 398 |
cerebras_api_key="test_cerebras_key",
|
| 399 |
fireworks_api_key="test_fireworks_key",
|
|
|
|
| 436 |
"github_models": GitHubModelsProvider,
|
| 437 |
"zai": OpenAIChatProvider,
|
| 438 |
"gemini": GeminiProvider,
|
| 439 |
+
"vertex": VertexProvider,
|
| 440 |
"groq": OpenAIChatProvider,
|
| 441 |
"sambanova": OpenAIChatProvider,
|
| 442 |
"cerebras": OpenAIChatProvider,
|
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Google Vertex AI authentication, discovery, and Chat Completions."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from unittest.mock import AsyncMock, patch
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
import pytest
|
| 8 |
+
from google.auth.credentials import Credentials
|
| 9 |
+
from google.auth.exceptions import DefaultCredentialsError, TransportError
|
| 10 |
+
from google.auth.transport.requests import Request
|
| 11 |
+
|
| 12 |
+
from free_claude_code.application.errors import ApplicationUnavailableError
|
| 13 |
+
from free_claude_code.config.provider_catalog import VERTEX_AI_API_ROOT
|
| 14 |
+
from free_claude_code.core.failures import ExecutionFailure, FailureKind
|
| 15 |
+
from free_claude_code.providers.base import ProviderConfig
|
| 16 |
+
from free_claude_code.providers.model_listing import ModelListResponseError
|
| 17 |
+
from free_claude_code.providers.vertex import VertexProvider
|
| 18 |
+
from free_claude_code.providers.vertex.auth import GoogleAccessTokenProvider
|
| 19 |
+
from free_claude_code.providers.vertex.endpoint import (
|
| 20 |
+
vertex_openai_base_url,
|
| 21 |
+
vertex_publisher_models_url,
|
| 22 |
+
vertex_service_endpoint,
|
| 23 |
+
)
|
| 24 |
+
from free_claude_code.providers.vertex.models import extract_vertex_model_page
|
| 25 |
+
from tests.providers.request_factory import make_messages_request
|
| 26 |
+
from tests.providers.support import passthrough_rate_limiter, reasoning_for
|
| 27 |
+
|
| 28 |
+
_PROJECT_ID = "my-project"
|
| 29 |
+
_GLOBAL_OPENAI_BASE = (
|
| 30 |
+
"https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/"
|
| 31 |
+
"endpoints/openapi"
|
| 32 |
+
)
|
| 33 |
+
_GLOBAL_MODELS_URL = (
|
| 34 |
+
"https://aiplatform.googleapis.com/v1beta1/publishers/google/models"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class FakeCredentials(Credentials):
|
| 39 |
+
"""Minimal mutable Google credentials for deterministic refresh tests."""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
*,
|
| 44 |
+
token: str | None = None,
|
| 45 |
+
expired: bool = True,
|
| 46 |
+
refresh_error: Exception | None = None,
|
| 47 |
+
) -> None:
|
| 48 |
+
super().__init__()
|
| 49 |
+
self.token = token
|
| 50 |
+
self._expired = expired
|
| 51 |
+
self._refresh_error = refresh_error
|
| 52 |
+
self.refresh_count = 0
|
| 53 |
+
self.refresh_request: object | None = None
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def expired(self) -> bool:
|
| 57 |
+
return self._expired
|
| 58 |
+
|
| 59 |
+
def refresh(self, request: object) -> None:
|
| 60 |
+
self.refresh_request = request
|
| 61 |
+
self.refresh_count += 1
|
| 62 |
+
if self._refresh_error is not None:
|
| 63 |
+
raise self._refresh_error
|
| 64 |
+
self.token = "refreshed-token"
|
| 65 |
+
self._expired = False
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _token_provider(
|
| 69 |
+
credentials: FakeCredentials | None = None,
|
| 70 |
+
) -> GoogleAccessTokenProvider:
|
| 71 |
+
value = credentials or FakeCredentials(token="access-token", expired=False)
|
| 72 |
+
|
| 73 |
+
def loader() -> Credentials:
|
| 74 |
+
return value
|
| 75 |
+
|
| 76 |
+
return GoogleAccessTokenProvider(loader)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _provider(
|
| 80 |
+
*,
|
| 81 |
+
location: str = "global",
|
| 82 |
+
token_provider: GoogleAccessTokenProvider | None = None,
|
| 83 |
+
) -> VertexProvider:
|
| 84 |
+
return VertexProvider(
|
| 85 |
+
ProviderConfig(api_key="", base_url=VERTEX_AI_API_ROOT),
|
| 86 |
+
project_id=_PROJECT_ID,
|
| 87 |
+
location=location,
|
| 88 |
+
rate_limiter=passthrough_rate_limiter(),
|
| 89 |
+
access_token_provider=token_provider or _token_provider(),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.parametrize(
|
| 94 |
+
("location", "service_endpoint"),
|
| 95 |
+
[
|
| 96 |
+
("global", "https://aiplatform.googleapis.com"),
|
| 97 |
+
("us-central1", "https://us-central1-aiplatform.googleapis.com"),
|
| 98 |
+
],
|
| 99 |
+
)
|
| 100 |
+
def test_vertex_endpoints_use_global_or_regional_hosts(
|
| 101 |
+
location: str, service_endpoint: str
|
| 102 |
+
) -> None:
|
| 103 |
+
assert vertex_service_endpoint(location) == service_endpoint
|
| 104 |
+
assert vertex_openai_base_url("project/name", location) == (
|
| 105 |
+
f"{service_endpoint}/v1/projects/project%2Fname/locations/{location}/"
|
| 106 |
+
"endpoints/openapi"
|
| 107 |
+
)
|
| 108 |
+
assert vertex_publisher_models_url(location) == (
|
| 109 |
+
f"{service_endpoint}/v1beta1/publishers/google/models"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@pytest.mark.parametrize("project_id", ["", " "])
|
| 114 |
+
def test_vertex_endpoint_requires_project_id(project_id: str) -> None:
|
| 115 |
+
with pytest.raises(ApplicationUnavailableError, match="VERTEX_PROJECT_ID"):
|
| 116 |
+
vertex_openai_base_url(project_id, "global")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@pytest.mark.parametrize("location", ["", "US central1", "us/central1"])
|
| 120 |
+
def test_vertex_endpoint_rejects_unsafe_locations(location: str) -> None:
|
| 121 |
+
with pytest.raises(ApplicationUnavailableError, match="VERTEX_LOCATION"):
|
| 122 |
+
vertex_service_endpoint(location)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@pytest.mark.asyncio
|
| 126 |
+
async def test_access_token_provider_reuses_valid_token_without_refresh() -> None:
|
| 127 |
+
credentials = FakeCredentials(token="cached-token", expired=False)
|
| 128 |
+
token_provider = _token_provider(credentials)
|
| 129 |
+
|
| 130 |
+
assert await token_provider() == "cached-token"
|
| 131 |
+
assert await token_provider() == "cached-token"
|
| 132 |
+
assert credentials.refresh_count == 0
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@pytest.mark.asyncio
|
| 136 |
+
async def test_access_token_provider_coalesces_concurrent_refreshes() -> None:
|
| 137 |
+
credentials = FakeCredentials()
|
| 138 |
+
token_provider = _token_provider(credentials)
|
| 139 |
+
|
| 140 |
+
tokens = await asyncio.gather(*(token_provider() for _ in range(10)))
|
| 141 |
+
|
| 142 |
+
assert tokens == ["refreshed-token"] * 10
|
| 143 |
+
assert credentials.refresh_count == 1
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@pytest.mark.asyncio
|
| 147 |
+
async def test_access_token_refresh_uses_the_vertex_proxy() -> None:
|
| 148 |
+
credentials = FakeCredentials()
|
| 149 |
+
token_provider = GoogleAccessTokenProvider(
|
| 150 |
+
lambda: credentials,
|
| 151 |
+
proxy="socks5://proxy.test:1080",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
assert await token_provider() == "refreshed-token"
|
| 155 |
+
assert isinstance(credentials.refresh_request, Request)
|
| 156 |
+
session = credentials.refresh_request.session
|
| 157 |
+
assert session.proxies == {
|
| 158 |
+
"http": "socks5://proxy.test:1080",
|
| 159 |
+
"https": "socks5://proxy.test:1080",
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@pytest.mark.asyncio
|
| 164 |
+
async def test_missing_adc_is_non_retryable_authentication_failure() -> None:
|
| 165 |
+
def missing_credentials() -> Credentials:
|
| 166 |
+
raise DefaultCredentialsError("sensitive local path")
|
| 167 |
+
|
| 168 |
+
token_provider = GoogleAccessTokenProvider(missing_credentials)
|
| 169 |
+
|
| 170 |
+
with pytest.raises(ExecutionFailure) as exc_info:
|
| 171 |
+
await token_provider()
|
| 172 |
+
|
| 173 |
+
failure = exc_info.value
|
| 174 |
+
assert failure.kind is FailureKind.AUTHENTICATION
|
| 175 |
+
assert failure.status_code == 401
|
| 176 |
+
assert failure.retryable is False
|
| 177 |
+
assert "gcloud auth application-default login" in failure.message
|
| 178 |
+
assert "sensitive local path" not in failure.message
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@pytest.mark.asyncio
|
| 182 |
+
async def test_transient_adc_refresh_failure_is_retryable() -> None:
|
| 183 |
+
credentials = FakeCredentials(
|
| 184 |
+
refresh_error=TransportError("temporary auth service failure")
|
| 185 |
+
)
|
| 186 |
+
token_provider = _token_provider(credentials)
|
| 187 |
+
|
| 188 |
+
with pytest.raises(ExecutionFailure) as exc_info:
|
| 189 |
+
await token_provider()
|
| 190 |
+
|
| 191 |
+
failure = exc_info.value
|
| 192 |
+
assert failure.kind is FailureKind.UNAVAILABLE
|
| 193 |
+
assert failure.status_code == 503
|
| 194 |
+
assert failure.retryable is True
|
| 195 |
+
assert "temporary auth service failure" not in failure.message
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_vertex_provider_supplies_renewable_token_callback_to_openai() -> None:
|
| 199 |
+
token_provider = _token_provider()
|
| 200 |
+
with (
|
| 201 |
+
patch(
|
| 202 |
+
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
|
| 203 |
+
) as openai_client,
|
| 204 |
+
patch("free_claude_code.providers.vertex.client.httpx.AsyncClient"),
|
| 205 |
+
):
|
| 206 |
+
provider = _provider(token_provider=token_provider)
|
| 207 |
+
|
| 208 |
+
assert provider._provider_name == "VERTEX"
|
| 209 |
+
assert provider._base_url == _GLOBAL_OPENAI_BASE
|
| 210 |
+
assert openai_client.call_args.kwargs["api_key"] is token_provider
|
| 211 |
+
assert openai_client.call_args.kwargs["default_headers"] == {
|
| 212 |
+
"x-goog-user-project": _PROJECT_ID
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_vertex_request_uses_google_thinking_budget_without_named_effort() -> None:
|
| 217 |
+
provider = _provider()
|
| 218 |
+
request = make_messages_request(
|
| 219 |
+
"google/gemini-3.5-flash",
|
| 220 |
+
thinking={"type": "enabled", "budget_tokens": 2048},
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
body = provider._build_request_body(request, reasoning=reasoning_for(request))
|
| 224 |
+
|
| 225 |
+
assert body["model"] == "google/gemini-3.5-flash"
|
| 226 |
+
assert "reasoning_effort" not in body
|
| 227 |
+
assert body["extra_body"]["extra_body"]["google"]["thinking_config"] == {
|
| 228 |
+
"include_thoughts": True,
|
| 229 |
+
"thinking_budget": 2048,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_vertex_request_maps_reasoning_off_to_zero_budget() -> None:
|
| 234 |
+
provider = _provider()
|
| 235 |
+
request = make_messages_request(
|
| 236 |
+
"google/gemini-3.5-flash",
|
| 237 |
+
thinking={"type": "disabled"},
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
body = provider._build_request_body(request, reasoning=reasoning_for(request))
|
| 241 |
+
|
| 242 |
+
assert body["extra_body"]["extra_body"]["google"]["thinking_config"] == {
|
| 243 |
+
"thinking_budget": 0,
|
| 244 |
+
"include_thoughts": False,
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def test_vertex_model_page_translates_google_resource_names_generically() -> None:
|
| 249 |
+
model_ids, page_token = extract_vertex_model_page(
|
| 250 |
+
{
|
| 251 |
+
"publisherModels": [
|
| 252 |
+
{"name": "publishers/google/models/gemini-3.5-flash"},
|
| 253 |
+
{"name": "publishers/acme/models/custom-chat"},
|
| 254 |
+
],
|
| 255 |
+
"nextPageToken": "next-page",
|
| 256 |
+
}
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
assert model_ids == frozenset({"google/gemini-3.5-flash", "acme/custom-chat"})
|
| 260 |
+
assert page_token == "next-page"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@pytest.mark.parametrize(
|
| 264 |
+
"payload",
|
| 265 |
+
[
|
| 266 |
+
None,
|
| 267 |
+
{},
|
| 268 |
+
{"publisherModels": "not-a-list"},
|
| 269 |
+
{"publisherModels": [{}]},
|
| 270 |
+
{"publisherModels": [{"name": "models/missing-publisher"}]},
|
| 271 |
+
{"publisherModels": [{"name": "publishers/google/models/ "}]},
|
| 272 |
+
{"publisherModels": [], "nextPageToken": 123},
|
| 273 |
+
],
|
| 274 |
+
)
|
| 275 |
+
def test_vertex_model_page_rejects_malformed_responses(payload: object) -> None:
|
| 276 |
+
with pytest.raises(ModelListResponseError, match="VERTEX model-list response"):
|
| 277 |
+
extract_vertex_model_page(payload)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@pytest.mark.asyncio
|
| 281 |
+
async def test_vertex_model_discovery_follows_native_pagination() -> None:
|
| 282 |
+
provider = _provider()
|
| 283 |
+
responses = [
|
| 284 |
+
httpx.Response(
|
| 285 |
+
200,
|
| 286 |
+
json={
|
| 287 |
+
"publisherModels": [
|
| 288 |
+
{"name": "publishers/google/models/gemini-3.5-flash"}
|
| 289 |
+
],
|
| 290 |
+
"nextPageToken": "page-2",
|
| 291 |
+
},
|
| 292 |
+
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
| 293 |
+
),
|
| 294 |
+
httpx.Response(
|
| 295 |
+
200,
|
| 296 |
+
json={
|
| 297 |
+
"publisherModels": [{"name": "publishers/google/models/gemini-3.1-pro"}]
|
| 298 |
+
},
|
| 299 |
+
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
| 300 |
+
),
|
| 301 |
+
]
|
| 302 |
+
with patch.object(
|
| 303 |
+
provider._model_list_client,
|
| 304 |
+
"get",
|
| 305 |
+
new_callable=AsyncMock,
|
| 306 |
+
side_effect=responses,
|
| 307 |
+
) as get:
|
| 308 |
+
model_ids = await provider.list_model_ids()
|
| 309 |
+
|
| 310 |
+
assert model_ids == frozenset({"google/gemini-3.5-flash", "google/gemini-3.1-pro"})
|
| 311 |
+
assert get.await_args_list[0].kwargs == {
|
| 312 |
+
"params": None,
|
| 313 |
+
"headers": {
|
| 314 |
+
"Authorization": "Bearer access-token",
|
| 315 |
+
"x-goog-user-project": _PROJECT_ID,
|
| 316 |
+
},
|
| 317 |
+
}
|
| 318 |
+
assert get.await_args_list[1].kwargs == {
|
| 319 |
+
"params": {"pageToken": "page-2"},
|
| 320 |
+
"headers": {
|
| 321 |
+
"Authorization": "Bearer access-token",
|
| 322 |
+
"x-goog-user-project": _PROJECT_ID,
|
| 323 |
+
},
|
| 324 |
+
}
|
| 325 |
+
assert all(response.is_closed for response in responses)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@pytest.mark.asyncio
|
| 329 |
+
@pytest.mark.parametrize(
|
| 330 |
+
"response",
|
| 331 |
+
[
|
| 332 |
+
httpx.Response(
|
| 333 |
+
200,
|
| 334 |
+
json={"publisherModels": []},
|
| 335 |
+
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
| 336 |
+
),
|
| 337 |
+
httpx.Response(
|
| 338 |
+
200,
|
| 339 |
+
content=b"not-json",
|
| 340 |
+
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
| 341 |
+
),
|
| 342 |
+
],
|
| 343 |
+
)
|
| 344 |
+
async def test_vertex_model_discovery_rejects_unusable_success_response(
|
| 345 |
+
response: httpx.Response,
|
| 346 |
+
) -> None:
|
| 347 |
+
provider = _provider()
|
| 348 |
+
with (
|
| 349 |
+
patch.object(
|
| 350 |
+
provider._model_list_client,
|
| 351 |
+
"get",
|
| 352 |
+
new_callable=AsyncMock,
|
| 353 |
+
return_value=response,
|
| 354 |
+
),
|
| 355 |
+
pytest.raises(ModelListResponseError, match="VERTEX model-list response"),
|
| 356 |
+
):
|
| 357 |
+
await provider.list_model_ids()
|
| 358 |
+
|
| 359 |
+
assert response.is_closed
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@pytest.mark.asyncio
|
| 363 |
+
async def test_vertex_model_discovery_rejects_repeated_page_token() -> None:
|
| 364 |
+
provider = _provider()
|
| 365 |
+
responses = [
|
| 366 |
+
httpx.Response(
|
| 367 |
+
200,
|
| 368 |
+
json={"publisherModels": [], "nextPageToken": "same"},
|
| 369 |
+
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
| 370 |
+
),
|
| 371 |
+
httpx.Response(
|
| 372 |
+
200,
|
| 373 |
+
json={"publisherModels": [], "nextPageToken": "same"},
|
| 374 |
+
request=httpx.Request("GET", _GLOBAL_MODELS_URL),
|
| 375 |
+
),
|
| 376 |
+
]
|
| 377 |
+
with (
|
| 378 |
+
patch.object(
|
| 379 |
+
provider._model_list_client,
|
| 380 |
+
"get",
|
| 381 |
+
new_callable=AsyncMock,
|
| 382 |
+
side_effect=responses,
|
| 383 |
+
),
|
| 384 |
+
pytest.raises(ModelListResponseError, match="repeated nextPageToken"),
|
| 385 |
+
):
|
| 386 |
+
await provider.list_model_ids()
|
|
@@ -318,6 +318,56 @@ wheels = [
|
|
| 318 |
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
| 319 |
]
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
[[package]]
|
| 322 |
name = "cuda-bindings"
|
| 323 |
version = "13.0.3"
|
|
@@ -570,12 +620,13 @@ wheels = [
|
|
| 570 |
|
| 571 |
[[package]]
|
| 572 |
name = "free-claude-code"
|
| 573 |
-
version = "4.
|
| 574 |
source = { editable = "." }
|
| 575 |
dependencies = [
|
| 576 |
{ name = "aiohttp" },
|
| 577 |
{ name = "discord-py" },
|
| 578 |
{ name = "fastapi", extra = ["standard"] },
|
|
|
|
| 579 |
{ name = "httpx", extra = ["socks"] },
|
| 580 |
{ name = "jsonschema" },
|
| 581 |
{ name = "loguru" },
|
|
@@ -585,6 +636,7 @@ dependencies = [
|
|
| 585 |
{ name = "pydantic-settings" },
|
| 586 |
{ name = "python-dotenv" },
|
| 587 |
{ name = "python-telegram-bot" },
|
|
|
|
| 588 |
{ name = "tiktoken" },
|
| 589 |
{ name = "uvicorn" },
|
| 590 |
]
|
|
@@ -618,6 +670,7 @@ requires-dist = [
|
|
| 618 |
{ name = "aiohttp", specifier = ">=3.14.1" },
|
| 619 |
{ name = "discord-py", specifier = ">=2.7.1" },
|
| 620 |
{ name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" },
|
|
|
|
| 621 |
{ name = "grpcio", marker = "extra == 'voice'", specifier = ">=1.82.1" },
|
| 622 |
{ name = "grpcio-tools", marker = "extra == 'voice'", specifier = ">=1.81.1" },
|
| 623 |
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
|
@@ -631,6 +684,7 @@ requires-dist = [
|
|
| 631 |
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
| 632 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 633 |
{ name = "python-telegram-bot", specifier = ">=22.8" },
|
|
|
|
| 634 |
{ name = "tiktoken", specifier = ">=0.13.0" },
|
| 635 |
{ name = "torch", marker = "extra == 'voice-local'", specifier = ">=2.13.0", index = "https://download.pytorch.org/whl/cu130" },
|
| 636 |
{ name = "transformers", marker = "extra == 'voice-local'", specifier = ">=5.14.1" },
|
|
@@ -698,6 +752,24 @@ wheels = [
|
|
| 698 |
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
|
| 699 |
]
|
| 700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
[[package]]
|
| 702 |
name = "grpcio"
|
| 703 |
version = "1.82.1"
|
|
@@ -1500,6 +1572,27 @@ wheels = [
|
|
| 1500 |
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
| 1501 |
]
|
| 1502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1503 |
[[package]]
|
| 1504 |
name = "pycparser"
|
| 1505 |
version = "3.0"
|
|
@@ -1606,6 +1699,15 @@ wheels = [
|
|
| 1606 |
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
| 1607 |
]
|
| 1608 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1609 |
[[package]]
|
| 1610 |
name = "pytest"
|
| 1611 |
version = "9.1.1"
|
|
@@ -1786,6 +1888,11 @@ wheels = [
|
|
| 1786 |
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
| 1787 |
]
|
| 1788 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1789 |
[[package]]
|
| 1790 |
name = "rich"
|
| 1791 |
version = "14.3.2"
|
|
|
|
| 318 |
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
| 319 |
]
|
| 320 |
|
| 321 |
+
[[package]]
|
| 322 |
+
name = "cryptography"
|
| 323 |
+
version = "49.0.0"
|
| 324 |
+
source = { registry = "https://pypi.org/simple" }
|
| 325 |
+
dependencies = [
|
| 326 |
+
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
| 327 |
+
]
|
| 328 |
+
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
| 329 |
+
wheels = [
|
| 330 |
+
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
| 331 |
+
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
| 332 |
+
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
| 333 |
+
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
| 334 |
+
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
| 335 |
+
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
| 336 |
+
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
| 337 |
+
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
| 338 |
+
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
| 339 |
+
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
| 340 |
+
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
| 341 |
+
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
| 342 |
+
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
| 343 |
+
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
|
| 344 |
+
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
|
| 345 |
+
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
|
| 346 |
+
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
|
| 347 |
+
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
|
| 348 |
+
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
|
| 349 |
+
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
|
| 350 |
+
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
|
| 351 |
+
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
|
| 352 |
+
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
|
| 353 |
+
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
|
| 354 |
+
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
|
| 355 |
+
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
|
| 356 |
+
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
| 357 |
+
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
| 358 |
+
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
| 359 |
+
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
| 360 |
+
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
| 361 |
+
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
| 362 |
+
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
| 363 |
+
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
| 364 |
+
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
| 365 |
+
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
| 366 |
+
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
| 367 |
+
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
| 368 |
+
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
| 369 |
+
]
|
| 370 |
+
|
| 371 |
[[package]]
|
| 372 |
name = "cuda-bindings"
|
| 373 |
version = "13.0.3"
|
|
|
|
| 620 |
|
| 621 |
[[package]]
|
| 622 |
name = "free-claude-code"
|
| 623 |
+
version = "4.11.0"
|
| 624 |
source = { editable = "." }
|
| 625 |
dependencies = [
|
| 626 |
{ name = "aiohttp" },
|
| 627 |
{ name = "discord-py" },
|
| 628 |
{ name = "fastapi", extra = ["standard"] },
|
| 629 |
+
{ name = "google-auth", extra = ["requests"] },
|
| 630 |
{ name = "httpx", extra = ["socks"] },
|
| 631 |
{ name = "jsonschema" },
|
| 632 |
{ name = "loguru" },
|
|
|
|
| 636 |
{ name = "pydantic-settings" },
|
| 637 |
{ name = "python-dotenv" },
|
| 638 |
{ name = "python-telegram-bot" },
|
| 639 |
+
{ name = "requests", extra = ["socks"] },
|
| 640 |
{ name = "tiktoken" },
|
| 641 |
{ name = "uvicorn" },
|
| 642 |
]
|
|
|
|
| 670 |
{ name = "aiohttp", specifier = ">=3.14.1" },
|
| 671 |
{ name = "discord-py", specifier = ">=2.7.1" },
|
| 672 |
{ name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" },
|
| 673 |
+
{ name = "google-auth", extras = ["requests"], specifier = ">=2.40.0" },
|
| 674 |
{ name = "grpcio", marker = "extra == 'voice'", specifier = ">=1.82.1" },
|
| 675 |
{ name = "grpcio-tools", marker = "extra == 'voice'", specifier = ">=1.81.1" },
|
| 676 |
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
|
|
|
| 684 |
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
| 685 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 686 |
{ name = "python-telegram-bot", specifier = ">=22.8" },
|
| 687 |
+
{ name = "requests", extras = ["socks"], specifier = ">=2.32.0" },
|
| 688 |
{ name = "tiktoken", specifier = ">=0.13.0" },
|
| 689 |
{ name = "torch", marker = "extra == 'voice-local'", specifier = ">=2.13.0", index = "https://download.pytorch.org/whl/cu130" },
|
| 690 |
{ name = "transformers", marker = "extra == 'voice-local'", specifier = ">=5.14.1" },
|
|
|
|
| 752 |
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
|
| 753 |
]
|
| 754 |
|
| 755 |
+
[[package]]
|
| 756 |
+
name = "google-auth"
|
| 757 |
+
version = "2.56.0"
|
| 758 |
+
source = { registry = "https://pypi.org/simple" }
|
| 759 |
+
dependencies = [
|
| 760 |
+
{ name = "cryptography" },
|
| 761 |
+
{ name = "pyasn1-modules" },
|
| 762 |
+
]
|
| 763 |
+
sdist = { url = "https://files.pythonhosted.org/packages/58/66/b4ba60005743e01933e22b4f62313e063f7460458b7d8a358427b4930013/google_auth-2.56.0.tar.gz", hash = "sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553", size = 364629, upload-time = "2026-07-13T19:09:57.143Z" }
|
| 764 |
+
wheels = [
|
| 765 |
+
{ url = "https://files.pythonhosted.org/packages/a8/7d/cd3e187f14ce832e419e70709bfcc40cb0dc11517d5d03c9d3919bcc3101/google_auth-2.56.0-py3-none-any.whl", hash = "sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0", size = 257976, upload-time = "2026-07-13T19:09:42.685Z" },
|
| 766 |
+
]
|
| 767 |
+
|
| 768 |
+
[package.optional-dependencies]
|
| 769 |
+
requests = [
|
| 770 |
+
{ name = "requests" },
|
| 771 |
+
]
|
| 772 |
+
|
| 773 |
[[package]]
|
| 774 |
name = "grpcio"
|
| 775 |
version = "1.82.1"
|
|
|
|
| 1572 |
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
| 1573 |
]
|
| 1574 |
|
| 1575 |
+
[[package]]
|
| 1576 |
+
name = "pyasn1"
|
| 1577 |
+
version = "0.6.4"
|
| 1578 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1579 |
+
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
| 1580 |
+
wheels = [
|
| 1581 |
+
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
| 1582 |
+
]
|
| 1583 |
+
|
| 1584 |
+
[[package]]
|
| 1585 |
+
name = "pyasn1-modules"
|
| 1586 |
+
version = "0.4.2"
|
| 1587 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1588 |
+
dependencies = [
|
| 1589 |
+
{ name = "pyasn1" },
|
| 1590 |
+
]
|
| 1591 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
| 1592 |
+
wheels = [
|
| 1593 |
+
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
| 1594 |
+
]
|
| 1595 |
+
|
| 1596 |
[[package]]
|
| 1597 |
name = "pycparser"
|
| 1598 |
version = "3.0"
|
|
|
|
| 1699 |
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
| 1700 |
]
|
| 1701 |
|
| 1702 |
+
[[package]]
|
| 1703 |
+
name = "pysocks"
|
| 1704 |
+
version = "1.7.1"
|
| 1705 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1706 |
+
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
|
| 1707 |
+
wheels = [
|
| 1708 |
+
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
| 1709 |
+
]
|
| 1710 |
+
|
| 1711 |
[[package]]
|
| 1712 |
name = "pytest"
|
| 1713 |
version = "9.1.1"
|
|
|
|
| 1888 |
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
| 1889 |
]
|
| 1890 |
|
| 1891 |
+
[package.optional-dependencies]
|
| 1892 |
+
socks = [
|
| 1893 |
+
{ name = "pysocks" },
|
| 1894 |
+
]
|
| 1895 |
+
|
| 1896 |
[[package]]
|
| 1897 |
name = "rich"
|
| 1898 |
version = "14.3.2"
|