Skip to content

Commit f97dec9

Browse files
committed
fix(server,client,docs): default-model alias, empty-content guard, truthful /v1/models, native-client contract
1 parent 5745234 commit f97dec9

7 files changed

Lines changed: 461 additions & 29 deletions

File tree

docs/migration.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,92 @@
11
# Migration Guide
22

3+
## Coming from the OpenAI SDK / LangChain
4+
5+
effGen ships an OpenAI-compatible HTTP server, so most code that already talks to
6+
the OpenAI API works by changing only the `base_url`. See
7+
[`server/openai-compat.md`](server/openai-compat.md) for the full endpoint,
8+
alias, streaming, and error-status reference.
9+
10+
### Point the official `openai` client at effGen
11+
12+
```python
13+
from openai import OpenAI
14+
15+
client = OpenAI(base_url="http://localhost:8000/v1", api_key="YOUR_EFFGEN_API_KEY")
16+
17+
resp = client.chat.completions.create(
18+
model="openai:gpt-5-nano", # route by provider:model
19+
messages=[{"role": "user", "content": "Summarize the CAP theorem."}],
20+
)
21+
print(resp.choices[0].message.content)
22+
```
23+
24+
- **Routing.** Send `provider:model` (`groq:llama-3.1-8b-instant`,
25+
`gemini:gemini-3.1-flash-lite`) or `provider/model`; a bare local id
26+
(`transformers:Qwen/Qwen2.5-1.5B-Instruct`) also loads. `effgen-default`
27+
routes to the server's configured default model. OpenAI flagship names
28+
(`gpt-4o-mini`, `gpt-3.5-turbo`) resolve to local models; the response's
29+
non-standard `effgen` object reports `resolved_model` and `alias_applied`.
30+
- **Streaming** works unchanged, including `stream_options={"include_usage":
31+
True}` for a final usage chunk. `response_format={"type": "json_object"}`,
32+
legacy `/v1/completions`, and `/v1/embeddings` are supported.
33+
- **Errors** map to the OpenAI status/type contract: unknown provider → 400,
34+
bad key → 401, unknown model → 404, rate limit → 429, upstream key
35+
missing/rejected → 503/502. `except openai.APIStatusError` code carries over.
36+
- **Cost** rides along on each response as the `effgen` extension
37+
(`resp.effgen["cost_usd"]` for priced models); OpenAI-only clients ignore it.
38+
39+
### Tools run server-side
40+
41+
This is the one place the protocol differs. effGen executes its **own**
42+
registered tools on the server and returns the final answer; it does **not**
43+
forward client-defined function tools for the caller to run, and it does not
44+
emit client-side `tool_calls` deltas. Request a registered tool by name:
45+
46+
```python
47+
resp = client.chat.completions.create(
48+
model="groq:llama-3.1-8b-instant",
49+
messages=[{"role": "user", "content": "What is 17 * 23?"}],
50+
tools=[{"type": "function", "function": {"name": "calculator"}}],
51+
)
52+
```
53+
54+
An unregistered tool name is refused with a 400 that points at
55+
`effgen tools list`.
56+
57+
### Native client
58+
59+
For a lighter dependency than the `openai` SDK, `effgen.client.EffGenClient`
60+
speaks the same server:
61+
62+
```python
63+
from effgen.client import EffGenClient
64+
65+
c = EffGenClient(base_url="http://localhost:8000", api_key="YOUR_EFFGEN_API_KEY")
66+
print(c.chat("Hello").content) # default model
67+
print(c.chat("What is 17*23?", tools=["calculator"]).content) # tool by name
68+
```
69+
70+
### Coming from LangChain
71+
72+
Point `ChatOpenAI` at the effGen server the same way:
73+
74+
```python
75+
from langchain_openai import ChatOpenAI
76+
77+
llm = ChatOpenAI(
78+
base_url="http://localhost:8000/v1",
79+
api_key="YOUR_EFFGEN_API_KEY",
80+
model="openai:gpt-5-nano",
81+
)
82+
```
83+
84+
Chains and prompt templates that call the model over the OpenAI protocol keep
85+
working. Move any client-side LangChain tool that must run on the server into an
86+
effGen registered tool (see `effgen tools list` and the tool authoring guide).
87+
88+
---
89+
390
## v0.1.x → v0.2.0
491

592
### Breaking Changes

docs/server/openai-compat.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ curl -H "Authorization: Bearer $EFFGEN_API_KEY" http://127.0.0.1:8000/v1/models
3434
|---|---|---|
3535
| POST | `/v1/chat/completions` | Chat completions (streaming + non-streaming) |
3636
| POST | `/v1/completions` | Legacy text completions |
37-
| GET | `/v1/models` | List the model aliases |
37+
| GET | `/v1/models` | List the aliases + ids served this run (not exhaustive) |
3838
| POST | `/v1/embeddings` | Text embeddings (local SentenceTransformer) |
3939

4040
## Python (official `openai` client)
@@ -64,6 +64,11 @@ OpenAI names map to concrete effGen models so OpenAI-only clients work:
6464
|---|---|
6565
| `gpt-4`, `gpt-4-turbo`, `gpt-4o` | `Qwen/Qwen2.5-7B-Instruct` |
6666
| `gpt-4o-mini`, `gpt-3.5-turbo` | `Qwen/Qwen2.5-3B-Instruct` |
67+
| `effgen-default`, `default` | the server's default model (`EFFGEN_DEFAULT_MODEL`, else `Qwen/Qwen2.5-3B-Instruct`) |
68+
69+
`GET /v1/models` lists these aliases plus every id the server has served a
70+
successful response for this run. The list is **not** exhaustive: any
71+
`provider:model` id the server can reach is callable whether or not it appears.
6772

6873
Aliasing is never silent. The response `model` field reports the model that
6974
actually ran, and a non-standard `effgen` object documents the mapping (OpenAI

effgen/api/openai_compat.py

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from __future__ import annotations
3131

3232
import json
33+
import os
3334
import re
3435
import time
3536
import uuid
@@ -59,9 +60,28 @@ def Field(default=None, **kwargs): # type: ignore
5960
"gpt-3.5-turbo-instruct": "Qwen/Qwen2.5-3B-Instruct",
6061
}
6162

63+
# Names that route to the server's configured default model. A caller that has
64+
# no particular model in mind (including the native client's zero-argument
65+
# ``chat("hi")``) can send ``"effgen-default"`` or ``"default"`` and get an
66+
# answer without knowing a concrete id. The target is read from
67+
# ``EFFGEN_DEFAULT_MODEL`` at request time, falling back to a small local model.
68+
DEFAULT_MODEL_ALIASES: frozenset[str] = frozenset({"effgen-default", "default"})
69+
_FALLBACK_DEFAULT_MODEL = "Qwen/Qwen2.5-3B-Instruct"
70+
71+
72+
def default_model_id() -> str:
73+
"""Return the model id the ``effgen-default``/``default`` names resolve to.
74+
75+
Controlled by ``EFFGEN_DEFAULT_MODEL``; defaults to a small local model when
76+
the variable is unset or blank.
77+
"""
78+
return os.environ.get("EFFGEN_DEFAULT_MODEL", "").strip() or _FALLBACK_DEFAULT_MODEL
79+
6280

6381
def resolve_model_alias(model: str) -> str:
6482
"""Resolve an OpenAI model name to a local effGen model id."""
83+
if model in DEFAULT_MODEL_ALIASES:
84+
return default_model_id()
6585
return MODEL_ALIASES.get(model, model)
6686

6787

@@ -501,9 +521,10 @@ def _effgen_meta(requested: str, resolved: str) -> dict[str, Any]:
501521
return {
502522
"requested_model": requested,
503523
"resolved_model": resolved,
504-
# True only when an OpenAI compatibility alias (gpt-4 → local model) was
505-
# applied — not for internal provider/model routing normalization.
506-
"alias_applied": requested in MODEL_ALIASES,
524+
# True when a compatibility alias (gpt-4 → local model) or the
525+
# effgen-default/default name was applied — not for internal
526+
# provider/model routing normalization.
527+
"alias_applied": requested in MODEL_ALIASES or requested in DEFAULT_MODEL_ALIASES,
507528
}
508529

509530

@@ -526,6 +547,32 @@ def _messages_to_prompt(messages: list[Any]) -> str:
526547
return "\n".join(parts)
527548

528549

550+
def _has_actionable_content(messages: list[Any]) -> bool:
551+
"""Return True if there is anything for the model to act on.
552+
553+
A request whose every message has empty/whitespace/``None``/absent text
554+
content — and no image parts, no ``tool_calls``, and no tool result — gives
555+
the model nothing to answer. Such a request is rejected with a 400 before a
556+
billed model call, matching the Agent layer's empty-task guard and OpenAI's
557+
own handling. A message counts as actionable when it has non-blank text, a
558+
non-empty multimodal content list, ``tool_calls``, or is a ``tool`` result.
559+
"""
560+
for msg in messages:
561+
is_dict = isinstance(msg, dict)
562+
role = msg.get("role") if is_dict else getattr(msg, "role", None)
563+
content = msg.get("content") if is_dict else getattr(msg, "content", None)
564+
tool_calls = msg.get("tool_calls") if is_dict else getattr(msg, "tool_calls", None)
565+
if isinstance(content, str) and content.strip():
566+
return True
567+
if isinstance(content, list) and content:
568+
return True
569+
if tool_calls:
570+
return True
571+
if role == "tool" and content is not None:
572+
return True
573+
return False
574+
575+
529576
def create_openai_router(
530577
runner: Runner, *, extra_models: Callable[[], list[str]] | None = None
531578
) -> Any:
@@ -559,15 +606,32 @@ def create_openai_router(
559606
def _error_response(exc: Exception) -> Any:
560607
"""Return an OpenAI-style, redacted error JSONResponse for *exc*."""
561608
status, err_type, code = _classify_http(exc)
609+
message = str(exc)
610+
# A bare/unknown model id falls through to the local loader and fails
611+
# with a Transformers "not a valid model identifier" message. Add a
612+
# one-line hint pointing at the id shapes the server accepts.
613+
low = message.lower()
614+
if code == "model_not_found" and (
615+
"not a valid model identifier" in low or "is not a local folder" in low
616+
):
617+
message += (
618+
" Pass a provider-prefixed model id (e.g. 'openai:gpt-5-nano', "
619+
"'groq:llama-3.1-8b-instant'), a valid local model id, or "
620+
"'effgen-default'."
621+
)
562622
return JSONResponse(
563623
status_code=status,
564-
content=_error_payload(str(exc), err_type, code),
624+
content=_error_payload(message, err_type, code),
565625
)
566626

567627
router = APIRouter(prefix="/v1", tags=["openai-compat"])
568628

569629
@router.get("/models")
570630
async def list_models() -> dict[str, Any]:
631+
# The list is the drop-in aliases plus the ids this process has actually
632+
# served a successful response for this run. It is not exhaustive: any
633+
# `provider:model` id the server can reach (e.g. "openai:gpt-5-nano",
634+
# "groq:llama-3.1-8b-instant") is callable whether or not it appears here.
571635
now = _now()
572636
data = [
573637
{
@@ -579,10 +643,19 @@ async def list_models() -> dict[str, Any]:
579643
}
580644
for alias, target in MODEL_ALIASES.items()
581645
]
646+
# The names that route to the server's configured default model.
647+
for name in sorted(DEFAULT_MODEL_ALIASES):
648+
data.append({
649+
"id": name,
650+
"object": "model",
651+
"created": now,
652+
"owned_by": "effgen",
653+
"root": default_model_id(),
654+
})
582655
# Alongside the drop-in legacy aliases, list the ids the server has
583-
# actually loaded and served this run (e.g. "openai:gpt-5-nano"),
584-
# so a client discovers real, currently-servable models instead of
585-
# only the 6 hardcoded OpenAI-flagship aliases.
656+
# actually served this run (e.g. "openai:gpt-5-nano"), so a client
657+
# discovers real, currently-servable models instead of only the
658+
# hardcoded aliases.
586659
if extra_models is not None:
587660
try:
588661
served = extra_models()
@@ -611,6 +684,17 @@ async def chat_completions(request: ChatCompletionRequest) -> Any:
611684
400, "messages must not be empty", code="empty_messages"
612685
),
613686
)
687+
# A request whose messages carry no text, no image, no tool_calls, and no
688+
# tool result gives the model nothing to answer. Reject it with a 400
689+
# before any billed model call rather than returning a paid-for
690+
# non-answer at 200.
691+
if not _has_actionable_content(request.messages):
692+
return JSONResponse(
693+
status_code=400,
694+
content=error_envelope(
695+
400, "message content must not be empty", code="empty_content"
696+
),
697+
)
614698
resolved = resolve_model_alias(request.model)
615699
model = resolved # echoed in the response 'model' field (what actually ran)
616700
prompt = _messages_to_prompt(request.messages)

effgen/client/client.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,14 @@ def _raise_for_status(self, status: int, payload: Any) -> None:
110110
return
111111
msg = f"HTTP {status}"
112112
if isinstance(payload, dict):
113-
msg = payload.get("error") or payload.get("message") or msg
113+
err = payload.get("error")
114+
if isinstance(err, dict):
115+
# OpenAI-style envelope: {"error": {"message": ..., "type": ...}}.
116+
msg = err.get("message") or err.get("code") or msg
117+
elif isinstance(err, str):
118+
msg = err
119+
else:
120+
msg = payload.get("message") or msg
114121
if status in (401, 403):
115122
raise EffGenAuthError(msg, status_code=status, payload=payload)
116123
if status == 429:
@@ -218,25 +225,30 @@ async def _request_async(
218225
def chat(
219226
self,
220227
message: str,
221-
tools: list[str] | None = None,
228+
tools: list[str | dict] | None = None,
222229
model: str | None = None,
223230
**kwargs: Any,
224231
) -> ChatResponse:
225-
"""Send a single-turn chat message and return the response."""
232+
"""Send a single-turn chat message and return the response.
233+
234+
``tools`` accepts registered tool names (``["calculator"]``) or full
235+
OpenAI tool specs; names are expanded to the server's tool-spec shape.
236+
The server runs the tool and returns the final answer.
237+
"""
226238
body: dict = {
227239
"model": model or "effgen-default",
228240
"messages": [{"role": "user", "content": message}],
229241
}
230242
if tools is not None:
231-
body["tools"] = tools
243+
body["tools"] = _tools_to_payload(tools)
232244
body.update(kwargs)
233245
payload = self._request_sync("POST", "/v1/chat/completions", body)
234246
return self._parse_chat(payload)
235247

236248
def embed(
237249
self,
238250
texts: list[str],
239-
model: str = "text-embedding-small",
251+
model: str = "text-embedding-3-small",
240252
) -> list[list[float]]:
241253
"""Compute embeddings for a list of input texts."""
242254
payload = self._request_sync(
@@ -287,7 +299,7 @@ def chat_stream_sync(self, message: str, model: str | None = None) -> Iterator[s
287299
async def achat(
288300
self,
289301
message: str,
290-
tools: list[str] | None = None,
302+
tools: list[str | dict] | None = None,
291303
model: str | None = None,
292304
**kwargs: Any,
293305
) -> ChatResponse:
@@ -296,13 +308,13 @@ async def achat(
296308
"messages": [{"role": "user", "content": message}],
297309
}
298310
if tools is not None:
299-
body["tools"] = tools
311+
body["tools"] = _tools_to_payload(tools)
300312
body.update(kwargs)
301313
payload = await self._request_async("POST", "/v1/chat/completions", body)
302314
return self._parse_chat(payload)
303315

304316
async def aembed(
305-
self, texts: list[str], model: str = "text-embedding-small"
317+
self, texts: list[str], model: str = "text-embedding-3-small"
306318
) -> list[list[float]]:
307319
payload = await self._request_async(
308320
"POST", "/v1/embeddings", {"model": model, "input": texts}
@@ -369,6 +381,27 @@ def _parse_chat(payload: Any) -> ChatResponse:
369381
)
370382

371383

384+
def _tools_to_payload(tools: list[Any]) -> list[dict]:
385+
"""Normalize tool references into the server's OpenAI tool-spec shape.
386+
387+
A registered tool may be named by a plain string (``"calculator"``); the
388+
server expects each tool as ``{"type": "function", "function": {"name":
389+
...}}``. A dict is passed through unchanged so callers can send a full spec.
390+
"""
391+
payload: list[dict] = []
392+
for tool in tools:
393+
if isinstance(tool, str):
394+
payload.append({"type": "function", "function": {"name": tool}})
395+
elif isinstance(tool, dict):
396+
payload.append(tool)
397+
else:
398+
raise EffGenAPIError(
399+
f"Unsupported tool reference {tool!r}: pass a registered tool "
400+
"name (str) or an OpenAI tool spec (dict)"
401+
)
402+
return payload
403+
404+
372405
def _parse_sse_line(line: str) -> str | None:
373406
"""Parse a single SSE line and return the text delta, or None."""
374407
if not line or not line.startswith("data:"):

0 commit comments

Comments
 (0)