LastNoob commited on
Commit
cabd2d4
·
unverified ·
1 Parent(s): cc8e5d2

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 -->

Files changed (36) hide show
  1. .env.example +9 -1
  2. ARCHITECTURE.md +19 -3
  3. README.md +6 -0
  4. pyproject.toml +3 -1
  5. smoke/lib/config.py +3 -12
  6. src/free_claude_code/api/admin_static/admin.js +2 -2
  7. src/free_claude_code/config/admin/provider_manifest.py +27 -0
  8. src/free_claude_code/config/admin/status.py +31 -5
  9. src/free_claude_code/config/provider_catalog.py +28 -0
  10. src/free_claude_code/config/settings.py +5 -0
  11. src/free_claude_code/providers/gemini/client.py +3 -50
  12. src/free_claude_code/providers/google_openai/__init__.py +10 -0
  13. src/free_claude_code/providers/google_openai/provider.py +99 -0
  14. src/free_claude_code/providers/{gemini → google_openai}/quirks.py +21 -16
  15. src/free_claude_code/providers/openai_chat/__init__.py +2 -1
  16. src/free_claude_code/providers/openai_chat/provider.py +5 -2
  17. src/free_claude_code/providers/runtime/config.py +10 -0
  18. src/free_claude_code/providers/runtime/discovery.py +2 -6
  19. src/free_claude_code/providers/runtime/factory.py +16 -0
  20. src/free_claude_code/providers/vertex/__init__.py +5 -0
  21. src/free_claude_code/providers/vertex/auth.py +105 -0
  22. src/free_claude_code/providers/vertex/client.py +126 -0
  23. src/free_claude_code/providers/vertex/endpoint.py +50 -0
  24. src/free_claude_code/providers/vertex/models.py +52 -0
  25. tests/api/test_admin.py +9 -0
  26. tests/config/test_config.py +14 -0
  27. tests/config/test_provider_catalog.py +15 -0
  28. tests/contracts/test_admin_provider_manifest.py +29 -0
  29. tests/contracts/test_feature_manifest.py +2 -0
  30. tests/contracts/test_provider_catalog_order.py +1 -0
  31. tests/contracts/test_smoke_config.py +19 -0
  32. tests/providers/test_gemini.py +3 -3
  33. tests/providers/test_model_validation.py +24 -0
  34. tests/providers/test_provider_runtime.py +6 -0
  35. tests/providers/test_vertex.py +386 -0
  36. uv.lock +108 -1
.env.example CHANGED
@@ -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=""
ARCHITECTURE.md CHANGED
@@ -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, and proxy support. It does not
512
- select a concrete adapter.
 
 
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 provider family:
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.
README.md CHANGED
@@ -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
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "free-claude-code"
7
- version = "4.10.0"
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]
smoke/lib/config.py CHANGED
@@ -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
- if descriptor.credential_attr:
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]:
src/free_claude_code/api/admin_static/admin.js CHANGED
@@ -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.credential_env;
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";
src/free_claude_code/config/admin/provider_manifest.py CHANGED
@@ -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():
src/free_claude_code/config/admin/status.py CHANGED
@@ -30,16 +30,35 @@ def provider_config_status(
30
  )
31
  continue
32
 
33
- value = str(state.get(descriptor.credential_env, {}).get("value", ""))
34
- configured = bool(value.strip())
 
 
 
 
 
 
 
35
  statuses.append(
36
  {
37
  "provider_id": provider_id,
38
  "display_name": descriptor.display_name,
39
  "kind": "remote",
40
- "status": "configured" if configured else "missing_key",
41
- "label": "Configured" if configured else "Missing key",
42
- "credential_env": descriptor.credential_env,
 
 
 
 
 
 
 
 
 
 
 
 
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}")
src/free_claude_code/config/provider_catalog.py CHANGED
@@ -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",
src/free_claude_code/config/settings.py CHANGED
@@ -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")
src/free_claude_code/providers/gemini/client.py CHANGED
@@ -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.anthropic.models import MessagesRequest
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(OpenAIChatProvider):
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
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/free_claude_code/providers/google_openai/__init__.py ADDED
@@ -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
+ ]
src/free_claude_code/providers/google_openai/provider.py ADDED
@@ -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
+ )
src/free_claude_code/providers/{gemini → google_openai}/quirks.py RENAMED
@@ -1,4 +1,4 @@
1
- """Gemini request-body quirks for the shared OpenAI-chat provider."""
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
- GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
10
 
11
 
12
- def apply_gemini_request_quirks(
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
- _apply_thinking_config(extra_body)
27
 
28
  if extra_body:
29
  body["extra_body"] = extra_body
30
 
31
- _apply_gemini_tool_call_signatures(
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, GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR
149
  )
150
 
151
 
152
- def _apply_gemini_tool_call_signatures(
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,
src/free_claude_code/providers/openai_chat/__init__.py CHANGED
@@ -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",
src/free_claude_code/providers/openai_chat/provider.py CHANGED
@@ -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,
src/free_claude_code/providers/runtime/config.py CHANGED
@@ -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:
src/free_claude_code/providers/runtime/discovery.py CHANGED
@@ -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 provider_credential
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.local
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
 
src/free_claude_code/providers/runtime/factory.py CHANGED
@@ -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
 
src/free_claude_code/providers/vertex/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Google Vertex AI OpenAI-compatible adapter."""
2
+
3
+ from .client import VertexProvider
4
+
5
+ __all__ = ["VertexProvider"]
src/free_claude_code/providers/vertex/auth.py ADDED
@@ -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
+ )
src/free_claude_code/providers/vertex/client.py ADDED
@@ -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)
src/free_claude_code/providers/vertex/endpoint.py ADDED
@@ -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
src/free_claude_code/providers/vertex/models.py ADDED
@@ -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}")
tests/api/test_admin.py CHANGED
@@ -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()
tests/config/test_config.py CHANGED
@@ -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
tests/config/test_provider_catalog.py CHANGED
@@ -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
+ )
tests/contracts/test_admin_provider_manifest.py CHANGED
@@ -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"
tests/contracts/test_feature_manifest.py CHANGED
@@ -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) == (
tests/contracts/test_provider_catalog_order.py CHANGED
@@ -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",
tests/contracts/test_smoke_config.py CHANGED
@@ -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(
tests/providers/test_gemini.py CHANGED
@@ -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.gemini.quirks import (
11
- GEMINI_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,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": GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR}
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
 
tests/providers/test_model_validation.py CHANGED
@@ -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(
tests/providers/test_provider_runtime.py CHANGED
@@ -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,
tests/providers/test_vertex.py ADDED
@@ -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()
uv.lock CHANGED
@@ -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.10.0"
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"