Skip to content

Commit 6717e14

Browse files
Add Atlas Cloud provider (#63)
* Add Atlas Cloud provider * chore: fix CI step --------- Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Co-authored-by: stefyi-4355 <as.peter@ime.life>
1 parent 3c2cf73 commit 6717e14

10 files changed

Lines changed: 235 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ from different vendors:
155155
Reports land in `./ifixai-results/` as JSON **and** Markdown. Without a second key, add
156156
`--eval-mode self` to run as a smoke test (the grade still prints, but it's flagged as
157157
self-judged, not a result you can cite). Pinning the judge, Full-mode ensembles, and the eval modes:
158-
**[docs/cli.md](docs/cli.md#how-a-run-is-judged)**. Other providers (OpenAI, OpenRouter, Gemini,
159-
Azure, Bedrock, Hugging Face) install the matching extra and follow the same steps; the
158+
**[docs/cli.md](docs/cli.md#how-a-run-is-judged)**. Other providers (OpenAI, Atlas Cloud,
159+
OpenRouter, Gemini, Azure, Bedrock, Hugging Face) install the matching extra and follow the same steps; the
160160
HTTP and LangChain adapters need no provider extra: **[docs/testing-your-agent.md](docs/testing-your-agent.md#provider-reference)**.
161161

162162
### Recommended judge setups

‎SECURITY.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ The scrubber (`ifixai/providers/secrets.py::scrub_secrets`) is parametrically ve
4040
| Provider | Credential shape matched | Redaction token |
4141
|---|---|---|
4242
| `openai` | `sk-[A-Za-z0-9_-]{20,}` | `***REDACTED_OPENAI_KEY***` |
43+
| `atlascloud` | `(ak|apikey)-[A-Za-z0-9_-]{20,}` | `***REDACTED_ATLASCLOUD_KEY***` |
4344
| `openrouter` | `sk-or-[A-Za-z0-9_-]{20,}` (matched before `openai`) | `***REDACTED_OPENROUTER_KEY***` |
4445
| `anthropic` | `sk-ant-[A-Za-z0-9_-]{20,}`, `anthropic_[A-Za-z0-9_-]{20,}` | `***REDACTED_ANTHROPIC_KEY***` |
4546
| `gemini` | `AIzaSy[0-9A-Za-z_-]{33}` | `***REDACTED_GEMINI_KEY***` |

‎docs/testing-your-agent.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ The scorecard adds a `warnings[]` note: declared, not measured at runtime. Field
6666
different provider grades the SUT, so nothing scores itself:
6767

6868
- Default judge: any non-SUT provider key in your env, on that provider's default model.
69-
- Multiple keys: tiebreaker order `anthropic → openai → gemini → openrouter → azure → bedrock → huggingface`.
69+
- Multiple keys: tiebreaker order `anthropic → openai → atlascloud → gemini → openrouter → azure → bedrock → huggingface`.
7070
- No non-SUT key: the run refuses unless you pass `--eval-mode self`.
7171
- Override: `--judge-provider` / `--judge-api-key` / `--judge-model`. Pin these for
7272
`openrouter` and `azure`: auto-routing can land SUT and judge on the same vendor.
@@ -108,6 +108,7 @@ the SUT key from the environment: pass `--api-key` / `-k`, or enter it when prom
108108
| `http` (recommended) | none | your server token | `--provider http --endpoint http://localhost:8000/v1 --grounding sut -k TOKEN --model your-model-id` |
109109
| `anthropic` | `.[anthropic]` | `ANTHROPIC_API_KEY` | `--provider anthropic -k "$ANTHROPIC_API_KEY" --model claude-sonnet-4-6` |
110110
| `openai` | `.[openai]` | `OPENAI_API_KEY` | `--provider openai -k "$OPENAI_API_KEY" --model gpt-4o` |
111+
| `atlascloud` | `.[atlascloud]` | `ATLASCLOUD_API_KEY` or `ATLAS_CLOUD_API_KEY` | `--provider atlascloud -k "$ATLASCLOUD_API_KEY" --model qwen/qwen3.5-flash` |
111112
| `openrouter` | `.[openrouter]` | `OPENROUTER_API_KEY` | `--provider openrouter -k "$OPENROUTER_API_KEY" --model openai/gpt-4o` plus explicit judge |
112113
| `gemini` | `.[gemini]` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `--provider gemini -k "$GEMINI_API_KEY"` |
113114
| `azure` | `.[azure]` | `AZURE_OPENAI_API_KEY` | `--provider azure --endpoint https://YOUR_RESOURCE.openai.azure.com/ -k "$AZURE_OPENAI_API_KEY" --model YOUR_DEPLOYMENT_NAME` plus explicit judge |

‎ifixai/cli/run.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def _governance_source_warning(source: str) -> str | None:
126126
PROVIDER_CHOICES = [
127127
"mock",
128128
"openai",
129+
"atlascloud",
129130
"gemini",
130131
"anthropic",
131132
"azure",

‎ifixai/providers/atlascloud.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import asyncio
2+
3+
import openai
4+
5+
from ifixai.core.types import ChatMessage, ProviderConfig
6+
from ifixai.providers.base import (
7+
ChatProvider,
8+
ProviderAuthError,
9+
ProviderConnectionError,
10+
ProviderEmptyContentError,
11+
ProviderRateLimitError,
12+
ProviderResponseError,
13+
ProviderTimeoutError,
14+
create_chat_completion_json_fallback,
15+
)
16+
17+
DEFAULT_MODEL = "qwen/qwen3.5-flash"
18+
DEFAULT_BASE_URL = "https://api.atlascloud.ai/v1"
19+
MAX_TOKENS_CEILING: int = 8192
20+
21+
ClientCacheKey = tuple[str, str | None, float, int]
22+
23+
24+
class AtlasCloudProvider(ChatProvider):
25+
26+
def __init__(self) -> None:
27+
self._clients: dict[ClientCacheKey, openai.AsyncOpenAI] = {}
28+
self._client_lock = asyncio.Lock()
29+
30+
async def get_client(self, config: ProviderConfig) -> openai.AsyncOpenAI:
31+
"""Return a long-lived AsyncOpenAI client keyed on connection params."""
32+
base_url = config.endpoint or DEFAULT_BASE_URL
33+
key: ClientCacheKey = (
34+
base_url,
35+
config.api_key,
36+
float(config.timeout),
37+
config.max_retries,
38+
)
39+
cached = self._clients.get(key)
40+
if cached is not None:
41+
return cached
42+
async with self._client_lock:
43+
cached = self._clients.get(key)
44+
if cached is not None:
45+
return cached
46+
client = openai.AsyncOpenAI(
47+
api_key=config.api_key,
48+
base_url=base_url,
49+
timeout=float(config.timeout),
50+
max_retries=config.max_retries,
51+
)
52+
self._clients[key] = client
53+
return client
54+
55+
async def aclose(self) -> None:
56+
for client in self._clients.values():
57+
await client.close()
58+
self._clients.clear()
59+
60+
async def send_message(
61+
self,
62+
messages: list[ChatMessage],
63+
config: ProviderConfig,
64+
) -> str:
65+
base_url = config.endpoint or DEFAULT_BASE_URL
66+
model = config.model or DEFAULT_MODEL
67+
formatted = [{"role": m.role, "content": m.content} for m in messages]
68+
69+
client = await self.get_client(config)
70+
try:
71+
effective_max_tokens = (
72+
min(config.max_tokens, MAX_TOKENS_CEILING)
73+
if config.max_tokens is not None
74+
else MAX_TOKENS_CEILING
75+
)
76+
create_kwargs: dict = {
77+
"model": model,
78+
"messages": formatted, # type: ignore[arg-type]
79+
"max_tokens": effective_max_tokens,
80+
"temperature": config.temperature,
81+
}
82+
if config.seed is not None:
83+
create_kwargs["seed"] = config.seed
84+
if config.json_output:
85+
create_kwargs["response_format"] = {"type": "json_object"}
86+
response = await create_chat_completion_json_fallback(client, **create_kwargs)
87+
choices = response.choices
88+
if not choices:
89+
raise ProviderResponseError(
90+
provider="atlascloud",
91+
endpoint=base_url,
92+
details=f"No choices in response (id={response.id})",
93+
)
94+
choice = choices[0]
95+
finish_reason = choice.finish_reason or "unknown"
96+
if choice.message is None:
97+
raise ProviderResponseError(
98+
provider="atlascloud",
99+
endpoint=base_url,
100+
details=f"Missing message in choice (finish_reason={finish_reason})",
101+
)
102+
content = choice.message.content
103+
if not content:
104+
raise ProviderEmptyContentError(
105+
provider="atlascloud",
106+
endpoint=base_url,
107+
details=f"Empty content in response (finish_reason={finish_reason})",
108+
)
109+
except openai.AuthenticationError as exc:
110+
raise ProviderAuthError(
111+
provider="atlascloud", endpoint=base_url, details=str(exc)
112+
) from exc
113+
except openai.RateLimitError as exc:
114+
raise ProviderRateLimitError(
115+
provider="atlascloud", endpoint=base_url, details=str(exc)
116+
) from exc
117+
except openai.APITimeoutError as exc:
118+
raise ProviderTimeoutError(
119+
provider="atlascloud", endpoint=base_url, details=str(exc)
120+
) from exc
121+
except openai.APIConnectionError as exc:
122+
raise ProviderConnectionError(
123+
provider="atlascloud", endpoint=base_url, details=str(exc)
124+
) from exc
125+
except openai.APIError as exc:
126+
raise ProviderResponseError(
127+
provider="atlascloud", endpoint=base_url, details=str(exc)
128+
) from exc
129+
else:
130+
return content

‎ifixai/providers/resolver.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
except ImportError:
4040
OpenAIProvider = None
4141

42+
try:
43+
from ifixai.providers.atlascloud import AtlasCloudProvider
44+
except ImportError:
45+
AtlasCloudProvider = None
46+
4247
try:
4348
from ifixai.providers.openrouter import OpenRouterProvider
4449
except ImportError:
@@ -54,6 +59,7 @@
5459
"http",
5560
"mock",
5661
"openai",
62+
"atlascloud",
5763
"openrouter",
5864
"anthropic",
5965
"gemini",
@@ -72,6 +78,7 @@
7278
for name, cls in {
7379
"http": HttpProvider,
7480
"openai": OpenAIProvider,
81+
"atlascloud": AtlasCloudProvider,
7582
"openrouter": OpenRouterProvider,
7683
"anthropic": AnthropicProvider,
7784
"gemini": GeminiProvider,
@@ -143,6 +150,7 @@ def resolve_provider(provider: str | object) -> object:
143150

144151
_PROVIDER_CREDENTIAL_ENV_VARS: dict[str, tuple[str, ...]] = {
145152
"openai": ("OPENAI_API_KEY",),
153+
"atlascloud": ("ATLASCLOUD_API_KEY", "ATLAS_CLOUD_API_KEY"),
146154
"anthropic": ("ANTHROPIC_API_KEY",),
147155
"gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
148156
"azure": ("AZURE_OPENAI_API_KEY",),
@@ -160,6 +168,7 @@ def resolve_provider(provider: str | object) -> object:
160168
_JUDGE_PREFERENCE_ORDER: tuple[str, ...] = (
161169
"anthropic",
162170
"openai",
171+
"atlascloud",
163172
"gemini",
164173
"openrouter",
165174
"azure",

‎ifixai/providers/secrets.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
ENV_VAR_BY_PROVIDER: dict[str, str] = {
77
"anthropic": "ANTHROPIC_API_KEY",
88
"openai": "OPENAI_API_KEY",
9+
"atlascloud": "ATLASCLOUD_API_KEY",
910
"gemini": "GEMINI_API_KEY",
1011
"azure": "AZURE_OPENAI_API_KEY",
1112
"bedrock": "AWS_ACCESS_KEY_ID",
@@ -14,6 +15,7 @@
1415

1516
_SECRET_PATTERNS: Final[tuple[Pattern[str], ...]] = (
1617
re.compile(r"^sk-or-[A-Za-z0-9_-]{20,}$"),
18+
re.compile(r"^ak-[A-Za-z0-9_-]{20,}$"),
1719
re.compile(r"^sk-ant-[A-Za-z0-9_-]{20,}$"),
1820
re.compile(r"^sk-[A-Za-z0-9_-]{20,}$"),
1921
re.compile(r"^anthropic_[A-Za-z0-9_-]{20,}$"),
@@ -28,6 +30,10 @@
2830
re.compile(r"sk-or-[A-Za-z0-9_-]{20,}"),
2931
"***REDACTED_OPENROUTER_KEY***",
3032
),
33+
(
34+
re.compile(r"(?<![A-Za-z0-9_-])ak-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])"),
35+
"***REDACTED_ATLASCLOUD_KEY***",
36+
),
3137
(
3238
re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"),
3339
"***REDACTED_ANTHROPIC_KEY***",

‎ifixai/reporting/scorecard.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def b32_not_applicable_warning(
234234
# aggregator (e.g. a Gemini SUT graded by a Claude judge, both via OpenRouter) is
235235
# recognized as independent instead of mislabeled "self-judge".
236236
_AGGREGATOR_PROVIDERS: Final[frozenset[str]] = frozenset(
237-
{"openrouter", "litellm", "http", "langchain"}
237+
{"openrouter", "atlascloud", "litellm", "http", "langchain"}
238238
)
239239

240240
# Distinct provider slugs that front the SAME underlying model vendor, so a

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040

4141
[project.optional-dependencies]
4242
openai = ["openai>=1.0"]
43+
atlascloud = ["openai>=1.0"]
4344
openrouter = ["openai>=1.0"]
4445
gemini = ["google-generativeai>=0.3"]
4546
anthropic = ["anthropic>=0.18"]

‎test_atlascloud_provider.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import pytest
2+
3+
from ifixai.core.types import ProviderConfig
4+
from ifixai.judge.config import JudgeConfig
5+
from ifixai.providers.atlascloud import (
6+
DEFAULT_BASE_URL,
7+
DEFAULT_MODEL,
8+
AtlasCloudProvider,
9+
)
10+
from ifixai.providers.resolver import (
11+
credential_env_vars,
12+
detect_available_credentials,
13+
resolve_credential,
14+
resolve_provider,
15+
select_cross_provider_judge,
16+
)
17+
from ifixai.providers.secrets import scrub_secrets
18+
from ifixai.reporting.scorecard import self_judge_bias_applies
19+
20+
21+
def test_atlascloud_resolves_and_reads_credentials() -> None:
22+
provider = resolve_provider("atlascloud")
23+
24+
assert isinstance(provider, AtlasCloudProvider)
25+
assert credential_env_vars("atlascloud") == (
26+
"ATLASCLOUD_API_KEY",
27+
"ATLAS_CLOUD_API_KEY",
28+
)
29+
assert resolve_credential(
30+
"atlascloud",
31+
{"ATLASCLOUD_API_KEY": "apikey-test-value-for-atlascloud"},
32+
) == "apikey-test-value-for-atlascloud"
33+
assert "atlascloud" in detect_available_credentials(
34+
{"ATLAS_CLOUD_API_KEY": "ak-test-value-for-atlascloud"}
35+
)
36+
37+
38+
def test_atlascloud_can_be_selected_as_independent_judge() -> None:
39+
assert select_cross_provider_judge("openai", ["openai", "atlascloud"]) == "atlascloud"
40+
assert select_cross_provider_judge("atlascloud", ["openai", "atlascloud"]) == "openai"
41+
42+
43+
@pytest.mark.asyncio
44+
async def test_atlascloud_client_uses_openai_compatible_defaults() -> None:
45+
provider = AtlasCloudProvider()
46+
config = ProviderConfig(provider="atlascloud", api_key="ak-test-value-for-atlascloud")
47+
48+
try:
49+
client = await provider.get_client(config)
50+
51+
assert str(client.base_url) == f"{DEFAULT_BASE_URL}/"
52+
assert provider._clients[
53+
(DEFAULT_BASE_URL, config.api_key, float(config.timeout), config.max_retries)
54+
] is client
55+
assert DEFAULT_MODEL == "qwen/qwen3.5-flash"
56+
finally:
57+
await provider.aclose()
58+
59+
60+
def test_atlascloud_keys_are_scrubbed() -> None:
61+
assert (
62+
scrub_secrets("token ak-abcdefghijklmnopqrstuvwxyz")
63+
== "token ***REDACTED_ATLASCLOUD_KEY***"
64+
)
65+
assert scrub_secrets("break-glass-escalation-path") == "break-glass-escalation-path"
66+
assert (
67+
scrub_secrets("token apikey-abcdefghijklmnopqrstuvwxyz")
68+
== "token apikey-abcdefghijklmnopqrstuvwxyz"
69+
)
70+
71+
72+
def test_atlascloud_is_treated_as_aggregator_for_bias_detection() -> None:
73+
assert not self_judge_bias_applies(
74+
JudgeConfig(provider="atlascloud", model="anthropic/claude-sonnet-4-6"),
75+
"openai",
76+
"gpt-4o",
77+
)
78+
assert self_judge_bias_applies(
79+
JudgeConfig(provider="atlascloud", model="openai/gpt-4o"),
80+
"openai",
81+
"gpt-4o",
82+
)

0 commit comments

Comments
 (0)