forked from retoor/devplacepy
Update
This commit is contained in:
@@ -4,7 +4,7 @@ This file documents the AI gateway subsystem (devplacepy/services/openai_gateway
|
||||
|
||||
`GatewayService` (`services/openai_gateway/`) is an OpenAI-compatible LLM gateway (the ported `openai5.py`), mounted at `/openai/v1/*` (`routers/openai_gateway.py`, prefix `/openai`). It is a **service that serves an HTTP endpoint** rather than a loop.
|
||||
|
||||
- The router is thin: it calls `service_manager.get_service("openai").handle(request, subpath)`. `POST /v1/chat/completions` runs the full gateway (vision augment -> model override -> forward -> optional SSE re-emit via `_fake_stream`); `POST /v1/embeddings` forwards to the configured embeddings upstream (`handle_embeddings`, no vision/streaming); `/v1/{path:path}` is a transparent passthrough to the upstream base. Disabled -> 503, unauthorized -> 401, upstream connection failure -> 502.
|
||||
- The router is thin: it calls `service_manager.get_service("openai").handle(request, subpath)`. `POST /v1/chat/completions` runs the full gateway (vision augment -> model override -> forward -> optional SSE re-emit via `_fake_stream`); `POST /v1/embeddings` forwards to the configured embeddings upstream (`handle_embeddings`, no vision/streaming); `GET /v1/models` is answered locally from the `gateway_models` chat routes (`_models_response`, publishing the public `molodetz`/`molodetz-pro` names), NOT proxied upstream; `/v1/{path:path}` is a transparent passthrough to the upstream base. **The gateway always forwards NON-streaming upstream** (`payload["stream"] = False`), then re-emits SSE itself when the client asked for `stream`; because `stream_options` is only valid alongside `stream=true`, `handle_chat` strips `stream_options` from the upstream payload (otherwise DeepSeek rejects it with `stream_options should be set along with stream = true`) and, when the client requested `stream_options.include_usage`, `_fake_stream` appends a final `choices: []` usage chunk built from the upstream `usage` before `[DONE]`. Disabled -> 503, unauthorized -> 401, upstream connection failure -> 502.
|
||||
- `main.py` registers it and exempts `/openai` from the rate-limit middleware and the maintenance gate. No new dependency (`httpx` already required).
|
||||
- `GatewayService` is `default_enabled=True` so internal callers work out of the box.
|
||||
|
||||
@@ -12,6 +12,15 @@ This file documents the AI gateway subsystem (devplacepy/services/openai_gateway
|
||||
|
||||
Every gateway response (chat, embeddings, passthrough; success and error) carries `X-Gateway-*` headers describing that single call: `Model`, `Backend`, `Prompt-Tokens`, `Completion-Tokens`, `Total-Tokens`, `Cache-Hit-Tokens`, `Cache-Miss-Tokens`, `Reasoning-Tokens`, `Cost-USD`/`Input-Cost-USD`/`Output-Cost-USD` (dollars), `Cost-Native` (1 if the upstream returned a native cost), `Tokens-Per-Second`, `Upstream-Latency-Ms`, and `Context-Window`/`Context-Utilization` when the model's window is known, plus the timing headers `X-Gateway-Upstream-Latency-Ms`/`X-Gateway-Total-Latency-Ms`. `GatewayUsageLedger.record(...)` RETURNS the computed ledger row (or `None` on failure); each handler's `finalize` closure maps it through `usage.usage_response_headers(row)` and attaches it to the returned `Response` (`resp_headers`). The single denied path with no upstream call (embeddings disabled) carries no headers. This is what lets any caller read its own spend - the AI correction worker reads `X-Gateway-Cost-USD`/token headers off its own correction call to accumulate per-user totals (see "AI content correction" in the root `CLAUDE.md`).
|
||||
|
||||
## App-reference header (`X-App-Reference`)
|
||||
|
||||
Callers may send an optional `X-App-Reference` header to tag gateway calls by application. The value is validated and stored in the `app_reference` column of `gateway_usage_ledger`, surfaced in analytics and admin reporting.
|
||||
|
||||
- **Validation** (`service._validate_app_reference`): trimmed whitespace, then matched against `^[a-zA-Z0-9_.-]{1,30}$`. Any value failing validation (empty, >30 chars, contains spaces or `@`/`/` etc.) silently falls back to `"default"`.
|
||||
- **Header name:** `X-App-Reference`.
|
||||
- **All internal callers** (news, bots, Devii, correction, jobs, deepsearch, dbapi, gitea) should pass a `devplace-<component>-v-<major>-<minor>-<patch>` value, e.g. `devplace-devii-v-1-0-0`, `devplace-news-v-1-0-0`.
|
||||
- The column is indexed (`CREATE INDEX IF NOT EXISTS`) for fast per-app queries.
|
||||
|
||||
## Per-worker runtime
|
||||
|
||||
**Serves in every worker.** Config/enabled come from `site_settings` (via the service's `get_config()`/`is_enabled()`), so any uvicorn worker answers - not just the supervisor worker. Per-worker runtime (`GatewayRuntime`: `httpx.AsyncClient` pool + `asyncio.Semaphore` sized to `gateway_instances`, plus counters and a `VisionCache`) is created lazily on first request and rebuilt when `instances`/`timeout`/cache size change. `gateway_instances` is the **scaling knob** (concurrency per worker); process scaling is uvicorn workers.
|
||||
@@ -83,7 +92,7 @@ Layered ON TOP of the single-provider service config above, which stays THE impl
|
||||
|
||||
**Resolution is a per-request overlay, not a fork.** At request time `routing.chat_overlay(requested, cfg)` / `routing.embed_overlay(requested, cfg)` / `routing.image_overlay(requested, cfg)` resolve an active route by the requested model name and return a per-request OVERLAY dict of `gateway_*` cfg keys (`gateway_force_model`+`gateway_model`=target, `gateway_upstream_url`/`gateway_api_key` from the provider, the price keys, an augmented `gateway_model_context_map`, and vision overlay keys); `handle_chat`/`handle_embeddings` merge it onto the base cfg (`cfg = {**cfg, **overlay}`) BEFORE everything else, so the existing model-selection / `pricing_from_cfg` / `parse_context_map` / vision / url+key paths transparently use the route's provider, target model, pricing, vision model and context window. `_ensure` (the httpx pool / semaphore / breaker / vision cache) reads only the non-overlaid pool keys, so the connection pool is never churned per request.
|
||||
|
||||
**No matching route = `None` overlay = byte-identical legacy behavior** - this is the "nobody feels the transformation" guarantee: `molodetz`/`molodetz~embed`/`molodetz-img-small` and every existing caller are byte-identical when no route matches. A route with a blank provider overlays only model+pricing(+vision), keeping the default upstream url/key.
|
||||
**No matching route = None overlay = model fallback.** When no route matches, the overlay returns `None` and the handler's model selection logic falls back to the default configured model (`gateway_model` for chat, `gateway_embed_model` for embeddings, `gateway_image_model` for images). The unknown model name is discarded, not forwarded upstream. This means an unknown or misspelled model name never causes a 4xx from the upstream - it is gracefully downgraded to the default. The `force_model` guard and the built-in `molodetz`/`molodetz~embed`/`molodetz-img-small` aliases are still respected before the route check: `force_model` or empty/named-alias model -> default directly; known model -> route resolution; unknown model with no route -> default fallback with a log message.
|
||||
|
||||
**CRUD.** Admin JSON at `/admin/gateway/{providers,models}` (`routers/admin/gateway_configs.py`, `require_admin`, Pydantic `ProviderIn`/`ModelRouteIn` validation, accepts both JSON from `static/js/GatewayAdmin.js` and form from Devii), audited under `gateway.provider.*`/`gateway.model.*` (category `ai`), included in the admin package with the page at `/admin/gateway` (`templates/admin_gateway.html`, sidebar link, `admin_section="gateway"`).
|
||||
|
||||
@@ -101,4 +110,4 @@ Real providers sometimes charge more than a flat per-1M rate for one component:
|
||||
- **Overlay + computation.** `chat_overlay`/`embed_overlay` propagate all of these into the per-request cfg dict under `gateway_*` keys exactly like the existing price fields (section above); `usage.pricing_from_cfg` reads them generically (defaulting to `None`/`0`/disabled when absent), so **Layer A (the single global flat Pricing config fields) never gains these dimensions** - only a `gateway_models` route can enable them, preserving the "no matching route = byte-identical legacy behavior" guarantee. `usage.compute_cost` selects tier1 vs tier2 per rate component (`_tiered_rate`) based on whether `norm["prompt"]` (billable input tokens) exceeds the threshold, then applies the off-peak discount (`_effective_rate`/`_off_peak_active`, UTC wraparound-aware) to whichever rate was selected. **The response header format and `compute_cost`'s return shape (`total, input_cost, output_cost, native`) are unchanged** - this is purely an internal rate-selection step before the existing input/output split math runs; a native upstream `cost` (OpenRouter) still overrides the modeled total exactly as before.
|
||||
- **Migration.** New `gateway_models` columns are added via `has_column`/`create_column_by_example` in `routing.ensure_tables()` (the `CREATE TABLE IF NOT EXISTS` DDL string alone would never reach a pre-existing table - see the `database/CLAUDE.md` column-ensure idiom).
|
||||
- **Admin UI.** `/admin/gateway`'s model-route form has a "Tiered / off-peak pricing (optional)" subsection; off-peak start/end render as `<input type="time">` (converted to/from UTC minutes-of-day by `GatewayAdmin.js`), and the routes table shows `tiered`/`off-peak` badges when a route has either dimension configured.
|
||||
- **DeepSeek's real pricing is already the tier-1 shape, not a new dimension.** DeepSeek's actual API (verified against `api-docs.deepseek.com/quick_start/pricing`) bills three flat per-1M rates - cache-hit input, cache-miss input, output - with no current context-length tier or off-peak window for the V4 models; that shape was already fully modeled by the pre-existing `chat_cache_hit_per_m`/`chat_cache_miss_per_m`/`chat_output_per_m` fields before this section's tier2/off-peak fields existed. `routing.seed_default_deepseek_routes()` (called once from `database.migrate_ai_gateway_settings()` at the end of `init_db()`) idempotently inserts two ready-made routes - `deepseek-v4-flash` (`$0.0028`/`$0.14`/`$0.28` per 1M, 1M context) and `deepseek-v4-pro` (`$0.003625`/`$0.435`/`$0.87` per 1M, 1M context) - only when that `source_model` row does not already exist, so a caller or Devii can request either name explicitly and get correctly-priced, decoupled from whatever the single global `gateway_model` default happens to be set to (switching that global setting between the two real models does NOT retroactively fix the flat Pricing config fields - the seeded routes are the model-agnostic, always-correct way to reference a specific priced model). Neither seeded route sets the tier2/off-peak fields (DeepSeek does not use them today); an admin can add them later on the same row if DeepSeek (or any other provider routed here) introduces such pricing.
|
||||
- **DeepSeek's real pricing is already the tier-1 shape, not a new dimension.** DeepSeek's actual API (verified against `api-docs.deepseek.com/quick_start/pricing`) bills three flat per-1M rates - cache-hit input, cache-miss input, output - with no current context-length tier or off-peak window for the V4 models; that shape was already fully modeled by the pre-existing `chat_cache_hit_per_m`/`chat_cache_miss_per_m`/`chat_output_per_m` fields before this section's tier2/off-peak fields existed. `routing.seed_default_deepseek_routes()` (called once from `database.migrate_ai_gateway_settings()` at the end of `init_db()`) idempotently inserts four ready-made routes - `deepseek-v4-flash` (`$0.0028`/`$0.14`/`$0.28` per 1M, 1M context), `deepseek-v4-pro` (`$0.003625`/`$0.435`/`$0.87` per 1M, 1M context), and the two public `molodetz` aliases `molodetz` -> `deepseek-v4-flash` (flash rates) and `molodetz-pro` -> `deepseek-v4-pro` (pro rates) - only when that `source_model` row does not already exist, so a caller or Devii can request any name explicitly and get correctly-priced, decoupled from whatever the single global `gateway_model` default happens to be set to (switching that global setting between the two real models does NOT retroactively fix the flat Pricing config fields - the seeded routes are the model-agnostic, always-correct way to reference a specific priced model). The `molodetz`/`molodetz-pro` aliases are the public model names; `GET /v1/models` is served locally from these `source_model` rows (not proxied upstream), so it publishes exactly the models the gateway accepts. Neither seeded route sets the tier2/off-peak fields (DeepSeek does not use them today); an admin can add them later on the same row if DeepSeek (or any other provider routed here) introduces such pricing.
|
||||
|
||||
@@ -33,7 +33,7 @@ from devplacepy.services.openai_gateway.vision import VisionAugmenter, VisionCac
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fake_stream(data: dict, model: str):
|
||||
def _fake_stream(data: dict, model: str, include_usage: bool = False):
|
||||
chunk_id = data.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
||||
created = data.get("created", int(time.time()))
|
||||
out_model = data.get("model", model)
|
||||
@@ -72,6 +72,21 @@ def _fake_stream(data: dict, model: str):
|
||||
for i in range(0, len(content), 50):
|
||||
yield _chunk({"content": content[i : i + 50]})
|
||||
yield _chunk({}, finish="tool_calls" if tool_calls else "stop")
|
||||
if include_usage and data.get("usage"):
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": out_model,
|
||||
"choices": [],
|
||||
"usage": data["usage"],
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return gen()
|
||||
@@ -217,7 +232,7 @@ class GatewayRuntime:
|
||||
return resp, None, timing
|
||||
|
||||
async def handle_chat(
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, app_reference: str, log=None
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
overlay = chat_overlay(body.get("model"), cfg)
|
||||
@@ -242,6 +257,7 @@ class GatewayRuntime:
|
||||
owner=owner,
|
||||
pricing=pricing,
|
||||
context_map=context_map,
|
||||
app_reference=app_reference,
|
||||
)
|
||||
messages = await augmenter.augment_messages(client, messages)
|
||||
self.vision_calls += augmenter.calls
|
||||
@@ -252,14 +268,19 @@ class GatewayRuntime:
|
||||
requested = body.get("model")
|
||||
if cfg["gateway_force_model"] or not requested or requested == "molodetz":
|
||||
model = cfg["gateway_model"]
|
||||
else:
|
||||
elif overlay is not None:
|
||||
model = requested
|
||||
else:
|
||||
model = cfg["gateway_model"]
|
||||
log(f"requested model {requested!r} has no route, falling back to {model!r}")
|
||||
|
||||
stream = bool(body.get("stream"))
|
||||
include_usage = bool((body.get("stream_options") or {}).get("include_usage"))
|
||||
payload = dict(body)
|
||||
payload["model"] = model
|
||||
payload["messages"] = messages
|
||||
payload["stream"] = False
|
||||
payload.pop("stream_options", None)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg["gateway_api_key"]:
|
||||
@@ -287,6 +308,7 @@ class GatewayRuntime:
|
||||
"endpoint": "chat/completions",
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
@@ -372,14 +394,18 @@ class GatewayRuntime:
|
||||
log(f"chat POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)")
|
||||
if stream:
|
||||
return StreamingResponse(
|
||||
_fake_stream(data, model),
|
||||
_fake_stream(data, model, include_usage),
|
||||
media_type="text/event-stream",
|
||||
headers=resp_headers,
|
||||
)
|
||||
return JSONResponse(content=data, headers=resp_headers)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
media_type="application/json",
|
||||
headers=resp_headers,
|
||||
)
|
||||
|
||||
async def handle_embeddings(
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, app_reference: str, log=None
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
vision_cost = 0.0
|
||||
@@ -425,8 +451,11 @@ class GatewayRuntime:
|
||||
requested = body.get("model")
|
||||
if cfg["gateway_force_model"] or not requested or requested == "molodetz~embed":
|
||||
model = cfg["gateway_embed_model"]
|
||||
else:
|
||||
elif overlay is not None:
|
||||
model = requested
|
||||
else:
|
||||
model = cfg["gateway_embed_model"]
|
||||
log(f"requested embed model {requested!r} has no route, falling back to {model!r}")
|
||||
|
||||
payload = dict(body)
|
||||
payload["model"] = model
|
||||
@@ -457,6 +486,7 @@ class GatewayRuntime:
|
||||
"endpoint": "embeddings",
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
@@ -544,7 +574,7 @@ class GatewayRuntime:
|
||||
return JSONResponse(content=data, headers=resp_headers)
|
||||
|
||||
async def handle_images(
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, app_reference: str, log=None
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
overlay = image_overlay(body.get("model"), cfg)
|
||||
@@ -595,8 +625,11 @@ class GatewayRuntime:
|
||||
or requested in ("molodetz-img-small", "molodetz-img")
|
||||
):
|
||||
model = cfg["gateway_image_model"]
|
||||
else:
|
||||
elif overlay is not None:
|
||||
model = requested
|
||||
else:
|
||||
model = cfg["gateway_image_model"]
|
||||
log(f"requested image model {requested!r} has no route, falling back to {model!r}")
|
||||
|
||||
payload = dict(body)
|
||||
payload["model"] = model
|
||||
@@ -627,6 +660,7 @@ class GatewayRuntime:
|
||||
"endpoint": "images/generations",
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
@@ -717,6 +751,7 @@ class GatewayRuntime:
|
||||
cfg: dict,
|
||||
owner: tuple,
|
||||
user_agent: str,
|
||||
app_reference: str,
|
||||
log=None,
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
@@ -746,6 +781,7 @@ class GatewayRuntime:
|
||||
"endpoint": subpath,
|
||||
"model": cfg["gateway_model"],
|
||||
"user_agent": user_agent,
|
||||
"app_reference": app_reference,
|
||||
**timing,
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,22 @@ DEEPSEEK_DEFAULT_ROUTES = (
|
||||
"price_cache_miss_per_m": 0.435,
|
||||
"price_output_per_m": 0.87,
|
||||
},
|
||||
{
|
||||
"source_model": "molodetz",
|
||||
"target_model": "deepseek-v4-flash",
|
||||
"context_window": 1_048_576,
|
||||
"price_cache_hit_per_m": 0.0028,
|
||||
"price_cache_miss_per_m": 0.14,
|
||||
"price_output_per_m": 0.28,
|
||||
},
|
||||
{
|
||||
"source_model": "molodetz-pro",
|
||||
"target_model": "deepseek-v4-pro",
|
||||
"context_window": 1_048_576,
|
||||
"price_cache_hit_per_m": 0.003625,
|
||||
"price_cache_miss_per_m": 0.435,
|
||||
"price_output_per_m": 0.87,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,18 +3,32 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.services.openai_gateway import config
|
||||
from devplacepy.services.openai_gateway.analytics import summary_metrics
|
||||
from devplacepy.services.openai_gateway.gateway import GatewayRuntime
|
||||
from devplacepy.services.openai_gateway.routing import model_store
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
|
||||
DEFAULT_APP_REFERENCE = "default"
|
||||
|
||||
|
||||
def _validate_app_reference(value: str) -> str:
|
||||
stripped = (value or "").strip()
|
||||
if not stripped or not APP_REFERENCE_PATTERN.match(stripped):
|
||||
return DEFAULT_APP_REFERENCE
|
||||
return stripped
|
||||
|
||||
|
||||
def _presented_key(request: Request) -> str:
|
||||
key = request.headers.get("X-API-KEY")
|
||||
@@ -449,6 +463,29 @@ class GatewayService(BaseService):
|
||||
return (kind, user.get("uid") or "unknown")
|
||||
return ("anonymous", "anonymous")
|
||||
|
||||
def _models_response(self) -> JSONResponse:
|
||||
created = int(time.time())
|
||||
seen: set = set()
|
||||
data = []
|
||||
for row in model_store.list():
|
||||
if str(row.get("kind") or "chat") != "chat":
|
||||
continue
|
||||
if not row.get("is_active", True):
|
||||
continue
|
||||
source = str(row.get("source_model") or "").strip()
|
||||
if not source or source in seen:
|
||||
continue
|
||||
seen.add(source)
|
||||
data.append(
|
||||
{
|
||||
"id": source,
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": "molodetz",
|
||||
}
|
||||
)
|
||||
return JSONResponse({"object": "list", "data": data})
|
||||
|
||||
async def handle(self, request: Request, subpath: str):
|
||||
if not self.is_enabled():
|
||||
raise HTTPException(status_code=503, detail="Gateway is disabled")
|
||||
@@ -459,6 +496,11 @@ class GatewayService(BaseService):
|
||||
runtime = self.runtime()
|
||||
owner = self.resolve_owner(request)
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
app_reference = _validate_app_reference(
|
||||
request.headers.get("X-App-Reference", DEFAULT_APP_REFERENCE)
|
||||
)
|
||||
if subpath == "models" and request.method == "GET":
|
||||
return self._models_response()
|
||||
if subpath == "chat/completions" and request.method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -468,7 +510,7 @@ class GatewayService(BaseService):
|
||||
if not isinstance(body, dict):
|
||||
self.log("Rejected chat request: JSON body was not an object")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
return await runtime.handle_chat(body, cfg, owner, user_agent, self.log)
|
||||
return await runtime.handle_chat(body, cfg, owner, user_agent, app_reference, self.log)
|
||||
if subpath == "embeddings" and request.method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -479,7 +521,7 @@ class GatewayService(BaseService):
|
||||
self.log("Rejected embeddings request: JSON body was not an object")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
return await runtime.handle_embeddings(
|
||||
body, cfg, owner, user_agent, self.log
|
||||
body, cfg, owner, user_agent, app_reference, self.log
|
||||
)
|
||||
if subpath == "images/generations" and request.method == "POST":
|
||||
try:
|
||||
@@ -491,7 +533,7 @@ class GatewayService(BaseService):
|
||||
self.log("Rejected images request: JSON body was not an object")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
return await runtime.handle_images(
|
||||
body, cfg, owner, user_agent, self.log
|
||||
body, cfg, owner, user_agent, app_reference, self.log
|
||||
)
|
||||
body = await request.body()
|
||||
content_type = request.headers.get("content-type", "")
|
||||
@@ -503,6 +545,7 @@ class GatewayService(BaseService):
|
||||
cfg,
|
||||
owner,
|
||||
user_agent,
|
||||
app_reference,
|
||||
self.log,
|
||||
)
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ def usage_response_headers(row: Optional[dict]) -> dict:
|
||||
headers["X-Gateway-Context-Window"] = str(int(row["context_window"]))
|
||||
if row.get("context_utilization") is not None:
|
||||
headers["X-Gateway-Context-Utilization"] = str(row["context_utilization"])
|
||||
headers["X-App-Reference"] = str(row.get("app_reference") or "default")
|
||||
return headers
|
||||
|
||||
|
||||
@@ -531,6 +532,7 @@ class GatewayUsageLedger:
|
||||
"retry_succeeded": 1 if raw.get("retry_succeeded") else 0,
|
||||
"circuit_open": 1 if raw.get("circuit_open") else 0,
|
||||
"user_agent": (raw.get("user_agent") or "")[:300],
|
||||
"app_reference": (raw.get("app_reference") or "default")[:30],
|
||||
}
|
||||
get_table(GATEWAY_LEDGER).insert(row)
|
||||
self._audit(raw, norm, cost_usd)
|
||||
@@ -551,6 +553,7 @@ class GatewayUsageLedger:
|
||||
success: bool,
|
||||
status_code: int,
|
||||
latency_ms: float = 0.0,
|
||||
app_reference: str = "default",
|
||||
) -> Optional[dict]:
|
||||
try:
|
||||
row = {
|
||||
@@ -591,6 +594,7 @@ class GatewayUsageLedger:
|
||||
"retry_succeeded": 0,
|
||||
"circuit_open": 0,
|
||||
"user_agent": "",
|
||||
"app_reference": app_reference or "default",
|
||||
}
|
||||
get_table(GATEWAY_LEDGER).insert(row)
|
||||
self._audit_external(row)
|
||||
@@ -695,6 +699,7 @@ def record_rsearch_call(
|
||||
cost_usd=cost,
|
||||
success=success,
|
||||
status_code=status_code,
|
||||
app_reference="devplace-devii-rsearch-v-1-0-0",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("rsearch usage ledger failed: %s", exc)
|
||||
|
||||
@@ -94,6 +94,7 @@ class VisionAugmenter:
|
||||
owner: tuple = ("unknown", "unknown"),
|
||||
pricing=None,
|
||||
context_map=None,
|
||||
app_reference: str = "default",
|
||||
):
|
||||
self.vision_url = vision_url
|
||||
self.vision_model = vision_model
|
||||
@@ -105,6 +106,7 @@ class VisionAugmenter:
|
||||
self.owner = owner
|
||||
self.pricing = pricing
|
||||
self.context_map = context_map or {}
|
||||
self.app_reference = app_reference
|
||||
self.calls = 0
|
||||
self.cost_usd = 0.0
|
||||
|
||||
@@ -126,6 +128,7 @@ class VisionAugmenter:
|
||||
"success": success,
|
||||
"error_category": category,
|
||||
"usage": usage,
|
||||
"app_reference": self.app_reference,
|
||||
},
|
||||
self.pricing,
|
||||
self.context_map,
|
||||
|
||||
Reference in New Issue
Block a user