Documentation
One gateway in front of every major model provider. Send the request shape your SDK already speaks and reach all of them — create a key, change the base URL, keep everything else.
Quickstart
Create a key, change the base URL, keep everything else. NicoSoft speaks three wire formats, so most SDKs work unmodified.
1. Call it from your SDK
from openai import OpenAI
client = OpenAI(
base_url="https://nicosoft.ai/v1",
api_key="sk-ns-...",
)
response = client.chat.completions.create(
model="deepseek/deepseek-v4-pro",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
import anthropic
client = anthropic.Anthropic(
base_url="https://nicosoft.ai",
api_key="sk-ns-...",
)
message = client.messages.create(
model="anthropic/claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(message.content[0].text)
from google import genai
client = genai.Client(
api_key="sk-ns-...",
http_options={"base_url": "https://nicosoft.ai"},
)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Hello!",
)
print(response.text)
2. Authentication
Every header your SDK might send is accepted, so you never have to fight it over which one it picks. Bearer tokens must carry the sk-ns- prefix. The key never travels in the query string: URLs are written down in access logs, traces and shell history, and none of those places are ours to purge.
3. Endpoints
| Path | Surface | Notes |
|---|---|---|
| /v1/chat/completions | openai | Chat Completions, streaming and non-streaming |
| /v1/responses | openai | Responses |
| /v1/messages | anthropic | Messages; count_tokens lives beside it |
| /v1beta/models/{model}:generateContent | gemini | :streamGenerateContent for streaming; image models answer here too |
| /v1/images/generations | openai | Synchronous image generation, image models only |
| /v1/media/image | nicosoft | Our own drawing protocol; nicosoft/image-preview only |
| /v1/videos | openai | Async video jobs: submit, poll /{id}, download /{id}/content |
| /v1/models | all | Model listing, in each surface's own shape |
4. Errors
Every response carries the request id in the x-request-id header (request-id for Anthropic SDKs); error bodies repeat it as request_id. Quote it when you contact support.
HTTP/1.1 402 Payment Required
x-request-id: 8f3d92c41b7a4e02a6d51c9f0b82e743
{
"error": {
"type": "invalid_request_error",
"message": "Insufficient balance, please deposit to your account.",
"code": "insufficient_balance",
"param": null,
"request_id": "8f3d92c41b7a4e02a6d51c9f0b82e743"
}
}
The error.type field follows each wire's own vocabulary; error.code is the stable machine-readable category, identical across surfaces. The ones you will actually see:
Chat Completions
The OpenAI-compatible surface. Any model whose Accepted column on the models page lists chat-completions can be called here.
Request
POST /v1/chat/completions — the standard Chat Completions body. Unknown parameters are forwarded to the model provider, so provider-specific options keep working.
curl https://nicosoft.ai/v1/chat/completions \
-H "Authorization: Bearer sk-ns-..." \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek/deepseek-v4-pro",
"messages": [{"role": "user", "content": "Hello!"}]
}'
Response
{
"id": "...",
"object": "chat.completion",
"model": "...",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello! How can I help?" },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21 }
}
The usage block is what billing reads; the same numbers appear on your Requests screen per call.
Streaming
Set "stream": true and the response arrives as server-sent events — one data: chunk per delta, closed by data: [DONE].
data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"Hel"}}]}
data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"lo!"}}]}
data: [DONE]
Errors that occur after the stream has opened arrive as a final data: frame carrying the standard error envelope.
Responses
The newer OpenAI surface. It serves the same openai-endpoint models as Chat Completions — pick whichever shape your tooling speaks.
Request
POST /v1/responses — the standard Responses body: input as a string or a message list.
from openai import OpenAI
client = OpenAI(
base_url="https://nicosoft.ai/v1",
api_key="sk-ns-...",
)
response = client.responses.create(
model="deepseek/deepseek-v4-pro",
input="Write a haiku about gateways.",
)
print(response.output_text)
Streaming
Set "stream": true and deltas arrive as named events from the official Responses stream vocabulary (response.output_text.delta and friends), ending with response.completed.
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"Gate"}
event: response.completed
data: {"type":"response.completed","response":{"usage":{...}}}
An error after the stream opens arrives as a named event: error frame.
Messages
The Anthropic-compatible surface, for models whose Accepted column lists messages. max_tokens is required, and the system prompt is the top-level system field — both exactly as the Anthropic API defines them.
Request
POST /v1/messages
curl https://nicosoft.ai/v1/messages \
-H "X-Api-Key: sk-ns-..." \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-opus-4-8",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello!"}]
}'
Response
{
"type": "message",
"role": "assistant",
"model": "...",
"content": [ { "type": "text", "text": "Hello! How can I help?" } ],
"stop_reason": "end_turn",
"usage": { "input_tokens": 10, "output_tokens": 12 }
}
Streaming
Set "stream": true and events follow the Anthropic vocabulary — message_start, content_block_delta, message_delta, message_stop. An error after the stream opens arrives as a named event: error.
Counting tokens
POST /v1/messages/count_tokens takes the same body (without max_tokens) and returns the input size without running the model:
{ "input_tokens": 10 }
Gemini
The Google-compatible surface, for models whose Accepted column lists generate-content. The model name lives in the path, and the key rides the x-goog-api-key header — which is what the Google SDKs send. Google's own REST samples put it in ?key= instead; that form is not accepted here.
Request
POST /v1beta/models/{model}:generateContent
curl https://nicosoft.ai/v1beta/models/gemini-2.5-flash:generateContent \
-H "x-goog-api-key: sk-ns-..." \
-H "Content-Type: application/json" \
-d '{
"contents": [{"parts": [{"text": "Hello!"}]}]
}'
Response
{
"candidates": [
{
"content": { "parts": [ { "text": "Hello! How can I help?" } ], "role": "model" },
"finishReason": "STOP"
}
],
"usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 11, "totalTokenCount": 15 }
}
Streaming
Use the :streamGenerateContent action. With ?alt=sse the chunks arrive as server-sent data: frames — this is what the official SDK sends. Without it, the response is one JSON array of chunks, exactly as the Google API behaves.
POST /v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse
data: {"candidates":[{"content":{"parts":[{"text":"Hel"}]}}]}
data: {"candidates":[{"content":{"parts":[{"text":"lo!"}]}}],"usageMetadata":{...}}
Image generation
Two surfaces draw: OpenAI's images endpoint and Gemini's generateContent, each in its own native shape. Both are billed per picture returned rather than per token — a model that draws is priced by the image.
OpenAI · request
POST /v1/images/generations — the standard Images body, forwarded as you send it, so provider options (size, quality, background, …) keep working. This endpoint has no streaming form.
curl https://nicosoft.ai/v1/images/generations \
-H "Authorization: Bearer sk-ns-..." \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-image-1-mini",
"prompt": "a tiny red cube on a white background, product photo",
"size": "1024x1024",
"n": 1
}'
OpenAI · response
{
"created": 1786445645,
"background": "opaque",
"data": [
{ "b64_json": "iVBORw0KGgoAAAANSUhEUgAA..." }
],
"usage": { "input_tokens": 17, "output_tokens": 272, "total_tokens": 289 }
}
Ask for n images and data carries n entries. The usage block is the provider's own, passed through as it arrived — for a per-image model it is informational, not the basis of the charge.
Gemini · inline images
An image model on :generateContent answers on the same endpoint as text, with the picture inline as an inlineData part.
curl https://nicosoft.ai/v1beta/models/gemini-2.5-flash-image:generateContent \
-H "x-goog-api-key: sk-ns-..." \
-H "Content-Type: application/json" \
-d '{
"contents": [{"parts": [{"text": "a tiny red cube on a white background"}]}]
}'
{
"candidates": [
{
"content": {
"parts": [
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAA..." } }
],
"role": "model"
},
"finishReason": "STOP"
}
],
"usageMetadata": { "promptTokenCount": 7, "candidatesTokenCount": 1417, "totalTokenCount": 1424 }
}
Streaming works too: with :streamGenerateContent the inlineData parts arrive in the frames as they are produced, and images are counted across the whole stream.
Billing
The charge is the number of images the provider actually returned times the model's per-image price, with the model's per-call minimum as the floor — the same on either surface. Token counts are recorded on the request for reference; they do not price it.
A 2xx that carries no image is not an answer: nothing is billed and the request is retried on another provider before you ever see it.
Picking a model
Image models are listed like every other model — they appear in /v1/models, /v1beta/models and on the models page, where the Type column tells them apart and prices them per image. Names carry the same prefix the chat models do: openai/gpt-image-1-mini on the OpenAI surface, gemini-2.5-flash-image on the Gemini one.
Each endpoint admits only the kind of model it can serve. A chat model sent to the images endpoint — or an image model sent to /v1/chat/completions — is refused before anything is spent:
Media · Image
Our own drawing protocol, on /v1/media/image. It is deliberately not the OpenAI Images shape: the model behind it cannot honour size, n or quality, and a schema that accepts fields it silently drops is worse than one that says so. Every response carries an applied block naming what actually happened.
Request
A prompt is the only thing required. One call takes 25–60 seconds (count: 4 can reach 110), so set a client timeout of at least 300 seconds.
curl https://nicosoft.ai/v1/media/image \
-H "Authorization: Bearer sk-ns-..." \
-H "Content-Type: application/json" \
--max-time 300 \
-d '{
"model": "nicosoft/image-preview",
"prompt": "a red maple leaf on a wooden table, soft morning light",
"aspect_ratio": "16:9"
}'
Fields
| Field | Level | Notes |
|---|---|---|
| prompt | required | Up to 4000 characters |
| aspect_ratio | best-effort | "16:9" or "1920x1080" — shape only, never resolution |
| transparent | best-effort | True alpha background. Read applied.transparent — see below |
| count | best-effort | 1–4; fewer may come back, applied.count is the truth |
| format | contract | png (default), jpeg, webp |
| compression | contract | 0–100, jpeg and webp only; png is lossless |
| images | contract | 0–16 inline data: URLs, 4 MB each. One edits, many compose |
| mask | contract | PNG with an alpha channel; needs exactly one entry in images |
| stream | contract | SSE. The terminal frame always arrives |
| preview | best-effort | Progressive frames, needs stream. Priced separately — see Billing |
There is no size, quality, n or seed. Those are not omissions — the upstream cannot act on them, and accepting a field we would drop is how a caller ends up debugging a request that was never wrong.
Response
{
"id": "media_9f3c1e0a-...",
"model": "nicosoft/image-preview",
"created": 1788734849,
"images": [
{ "b64": "iVBORw0KGgo...", "format": "png",
"width": 1672, "height": 941, "bytes": 1527808 }
],
"applied": {
"aspect_ratio": "16:9", "transparent": false,
"count": 1, "format": "png", "quality": "medium"
},
"revised_prompt": "A red maple leaf resting on weathered oak...",
"usage": {
"input_tokens": 2321, "output_tokens": 51, "total_tokens": 2372,
"image_input_tokens": 0, "image_output_tokens": 229, "image_text_tokens": 0
}
}
applied describes the bytes you were actually sent, not the request you made — judge success by it. b64 is raw base64 with no data: prefix. revised_prompt may be absent entirely; do not depend on it.
Size: you choose the shape, not the resolution
The pixel budget is fixed at roughly 1.57 million pixels. aspect_ratio spends that budget in a different shape — it does not buy more of it. No aspect ratio is sharper than another; scale on the client if you need exact dimensions.
| aspect_ratio | You get | Notes |
|---|---|---|
| (omitted) | 1254 x 1254 | Square is the upstream default |
| 16:9 | 1672 x 941 | Wide shapes are cheaper per image and no less sharp |
| 2:3 | 1024 x 1536 | Portrait |
Transparency is best-effort — check applied.transparent
Whether a request comes back with a real alpha channel depends on the upstream account that served it. Measured across the pool: roughly four in five accounts produce true alpha, the rest never do, and an individual account is consistent either way. The contract holds regardless — applied.transparent always tells the truth about the bytes — so a caller that needs alpha should read it and retry, which usually lands on a different account.
out = generate(prompt, transparent=True)
if not out["applied"]["transparent"]:
out = generate(prompt, transparent=True) # a second try usually draws another account
transparent cannot be combined with format: "jpeg" — JPEG has no alpha channel at all, so the pair is refused rather than silently resolved.
Editing and composition
Send source images inline in images. One entry edits that picture, several compose them. https:// URLs are refused: fetching a caller-supplied address is a request-forgery primitive, and reading the bytes yourself costs nothing you were not already paying.
A mask confines the edit. It must be a PNG that carries an alpha channel, and it uses the standard polarity — transparent marks the region to change, opaque is kept. A mask without an alpha channel is refused rather than accepted as a no-op, because "200 and nothing changed" is the worst answer available.
{
"model": "nicosoft/image-preview",
"prompt": "put a yellow rubber duck here",
"images": ["data:image/png;base64,iVBORw0KGgo..."],
"mask": "data:image/png;base64,iVBORw0KGgo..."
}
Sending a larger source image does not buy detail: the upstream down-samples on arrival and caps what one image can cost, so an oversized upload only spends bandwidth and latency.
Streaming
With stream: true the response is text/event-stream carrying three event names. Each frame repeats its own name in a type field, so a client that reads only data: lines can still tell them apart without holding the previous line.
event: image.partial
data: {"type":"image.partial","index":0,"b64":"...","format":"png"}
event: image.done
data: {"type":"image.done","id":"media_...","images":[...],"applied":{...},"usage":{...}}
event: image.error
data: {"type":"image.error","error":{"code":"upstream_error","message":"..."}}
stream on its own does not request progressive frames; preview: true does, and it is a separate field because it is separately expensive (see Billing). Streaming guarantees the terminal frame arrives; it does not guarantee how many partial frames precede it, and it does not make the first byte arrive sooner.
Errors
Same envelope as everywhere else on the gateway. Quote request_id when reporting one.
| code | HTTP | When |
|---|---|---|
| missing_required_field | 400 | No model, or no prompt |
| invalid_parameter | 400 | count out of range, unknown format, malformed aspect_ratio |
| unsupported_capability | 400 | transparent with jpeg; a mask with no image; an https: URL |
| media_format_unsupported | 400 | An image will not decode, or a mask carries no alpha channel |
| request_too_large | 413 | A single inline image above 4 MB |
| wrong_endpoint | 400 | Model and endpoint disagree; the message names the right one |
Billing
Priced per million tokens like a text model, against the input_tokens and output_tokens in usage. The three image_* counters beside them are informational and are not charged — a picture's own tokens are on us.
A typical text-to-image call is about 2.3K input tokens, near US$0.03. Turning on preview raises the input count to roughly 4.2K — about 73% more on the bill — because each progressive frame is fed back through the model that drew it. Ask for it when a person is watching, not by default.
Video generation
Video is a job, not a request: submit it, poll it, then download the bytes. The three endpoints follow the OpenAI video shape, and every response body is the provider's own, passed through untouched.
1. Submit
POST /v1/videos — answers as soon as the job is queued, long before the video exists.
curl https://nicosoft.ai/v1/videos \
-H "Authorization: Bearer sk-ns-..." \
-H "Content-Type: application/json" \
-d '{
"model": "openai/sora-2",
"prompt": "a red cube slowly rotating on a white studio background",
"seconds": "4",
"size": "720x1280"
}'
{
"id": "video_6a7aff712a688193a7f0eb8a9c123fbc",
"object": "video",
"created_at": 1786445681,
"status": "queued",
"progress": 0,
"seconds": "4",
"size": "720x1280"
}
2. Poll
GET /v1/videos/{id} — the status walks queued → in_progress (with a progress percentage) → completed. Polling is free.
GET /v1/videos/video_6a7aff712a688193a7f0eb8a9c123fbc
{"id":"video_6a7aff...","status":"in_progress","progress":99,"seconds":"4"}
3. Download
GET /v1/videos/{id}/content streams the finished file back with the provider's own content type; a ?variant= parameter is passed through. Downloading is free, and you can download as often as the provider keeps the job.
curl https://nicosoft.ai/v1/videos/video_6a7aff.../content \ -H "Authorization: Bearer sk-ns-..." \ -o video.mp4
Billing and ownership
The charge lands once, at submit: the requested duration in seconds times the model's per-second price, with the model's minimum duration as the floor. Polls and downloads are never billed. If the provider ends up reporting the job as failed, the charge is refunded automatically the first time you poll it.
A job belongs to the account that created it. Any other account asking after it gets the same answer as for an id that never existed:
Model listing
Each surface lists the models it can serve, in its own native shape. Custom models you define in the console appear in your own listing alongside the catalog.
OpenAI shape
GET /v1/models
{
"object": "list",
"data": [
{ "id": "deepseek/deepseek-v4-pro", "object": "model", "owned_by": "deepseek" },
{ "id": "anthropic/claude-opus-4-8", "object": "model", "owned_by": "anthropic" }
]
}
Anthropic shape
The Anthropic SDK builds the same absolute URL — its base has no /v1 and it appends /v1/models. The shapes are told apart by the anthropic-version header, which every Anthropic client sends and no OpenAI client does.
{
"data": [
{ "type": "model", "id": "anthropic/claude-opus-4-8", "display_name": "Claude Opus 4.8" }
],
"has_more": false
}
Gemini shape
GET /v1beta/models
{
"models": [
{ "name": "models/gemini-2.5-flash", "displayName": "gemini-2.5-flash" }
]
}
Each list is filtered to what that surface can actually serve, so a name you pick off a listing is always callable on the surface that listed it. Chat, image and video models all appear; which endpoint of the surface takes a given name follows from what it produces — see image generation and video generation.
Rate limits
The platform imposes no request quota of its own. Every key starts unlimited; you can give a key its own ceilings — a spend limit, a requests-per-minute cap, an expiry — from the console, to contain a leaked or shared key. Beyond those, what gates a request is your balance, the platform's live capacity, and a few abuse protections — each with its own status code, so your retry logic can tell them apart.
What can stop a request
| Status | Code | Meaning |
|---|---|---|
| 402 | insufficient_balance | Checked before any work is done. Deposit and retry. |
| 403 | api_key_credit_limit | The key reached the spend limit you set on it. Raise the limit or use another key. |
| 429 | rate_limit_exceeded | The key exceeded the per-minute cap you set on it. Slow down, or raise the cap. |
| 413 | request_too_large | The request body exceeds the size cap (10 MB by default). |
| 503 | capacity_exceeded | Every route to the model is saturated right now. Back off and retry. |
| 503 | model_temporarily_unavailable | The model has no serving route at the moment. Try again, or another model. |
| 403 | ip_blocked | The caller's network is not permitted. |
Handling them
Treat 5xx and 429 as retryable with exponential backoff — the official OpenAI, Anthropic and Google SDKs already do this by default, so pointing them at NicoSoft keeps their retry behavior. 4xx responses are not retryable: the request itself must change.
Prefer streaming for long generations — it holds one connection instead of retrying long polls, and you see failures at the moment they happen. When contacting support about a limit, quote the x-request-id of a rejected call.
Data retention
The short version: your prompts and the models' responses are never stored. What is kept is the metadata that bills and operates the service.
Never stored
Request and response content — prompts, messages, files, generated text — passes through the gateway to complete each call and is not persisted anywhere. It is also never used to train models.
What is kept
| Data | Window | Why |
|---|---|---|
| Request metadata (model, endpoint, token counts, latency, status, cost) | Life of the account | Billing record — it is your Requests screen |
| Credit ledger and deposit orders | As tax rules require | Accounting |
| Operational error events | ~90 days | Debugging and abuse investigation |
| Security emails log (codes, notices) | ~90 days | Delivery troubleshooting |
Upstream providers
Requests are completed by third-party model providers, which receive the prompt they serve and apply their own retention and abuse-monitoring policies. Review the relevant provider's terms before routing sensitive data. The full statement of rights and windows lives in the Privacy Policy.