From 559f7b64e8e4e92a5365a0b806ac45b469aa6733 Mon Sep 17 00:00:00 2001 From: Oussama Mahjoub Date: Thu, 6 Aug 2026 13:21:18 +0100 Subject: [PATCH] feat(api): Upgrade To Last API Spec --- .github/workflows/ci.yml | 4 + CHANGELOG.md | 60 + README.md | 14 +- api-spec/openapi.json | 521 +- api-spec/openapi.yaml | 6043 +++++++++++++++++ .../main/java/qa/fanar/core/ErrorCode.java | 5 +- .../FanarClientClosedRequestException.java | 22 + .../qa/fanar/core/FanarClientException.java | 3 +- .../java/qa/fanar/core/audio/AudioClient.java | 34 +- .../qa/fanar/core/audio/AvailableVoice.java | 47 + .../fanar/core/audio/TextToSpeechRequest.java | 61 +- .../main/java/qa/fanar/core/audio/Voice.java | 8 +- .../qa/fanar/core/audio/VoiceResponse.java | 13 +- .../java/qa/fanar/core/audio/VoiceType.java | 37 + .../java/qa/fanar/core/chat/ChatModel.java | 9 +- .../java/qa/fanar/core/chat/ChatRequest.java | 25 +- .../main/java/qa/fanar/core/chat/Madhab.java | 47 + .../core/images/ImageGenerationItem.java | 12 +- .../core/images/ImageGenerationRequest.java | 12 +- .../core/internal/audio/AudioClientImpl.java | 26 + .../internal/audio/AudioStreamPublisher.java | 157 + .../core/internal/chat/ChatClientImpl.java | 29 +- .../internal/transport/ErrorEnvelope.java | 228 + .../internal/transport/ExceptionMapper.java | 66 +- .../core/internal/transport/StreamFlag.java | 53 + .../qa.fanar/fanar-core/reflect-config.json | 16 +- .../qa/fanar/core/FanarExceptionTest.java | 8 +- .../fanar/core/audio/AvailableVoiceTest.java | 68 + .../core/audio/TextToSpeechRequestTest.java | 56 +- .../fanar/core/audio/VoiceResponseTest.java | 18 +- .../java/qa/fanar/core/audio/VoiceTest.java | 4 +- .../qa/fanar/core/audio/VoiceTypeTest.java | 35 + .../qa/fanar/core/chat/ChatModelTest.java | 3 +- .../qa/fanar/core/chat/ChatRequestTest.java | 29 +- .../java/qa/fanar/core/chat/MadhabTest.java | 35 + .../core/images/ImageGenerationItemTest.java | 9 +- .../images/ImageGenerationRequestTest.java | 14 +- .../images/ImageGenerationResponseTest.java | 8 +- .../internal/audio/AudioClientImplTest.java | 83 +- .../audio/AudioStreamPublisherTest.java | 343 + .../internal/images/ImagesClientImplTest.java | 2 +- .../internal/transport/ErrorEnvelopeTest.java | 115 + .../transport/ExceptionMapperTest.java | 94 + .../internal/transport/StreamFlagTest.java | 40 + docs/API_SKETCH.md | 52 +- docs/ARCHITECTURE.md | 31 +- docs/COMPATIBILITY.md | 15 +- docs/GLOSSARY.md | 14 +- docs/PROJECT_STATE.md | 23 +- docs/adr/006-unchecked-exception-hierarchy.md | 29 +- .../023-streaming-tts-via-flow-publisher.md | 82 + docs/adr/024-spring-ai-vendor-options.md | 83 + docs/adr/INDEX.md | 2 + .../fanar_java_runtime_architecture.svg | 2 +- .../main/java/qa/fanar/e2e/graalvm/Main.java | 29 +- .../java/qa/fanar/e2e/AdapterParityTest.java | 62 +- e2e/src/test/java/qa/fanar/e2e/Probes.java | 38 + .../fanar/e2e/audio/LiveAudioSpeechTest.java | 64 +- .../e2e/audio/LiveAudioTranscriptionTest.java | 48 +- .../fanar/e2e/audio/LiveAudioVoicesTest.java | 19 +- .../e2e/chat/LiveChatCompletionsTest.java | 29 + .../qa/fanar/e2e/images/LiveImagesTest.java | 3 + .../qa/fanar/e2e/models/LiveModelsTest.java | 29 +- .../qa/fanar/e2e/poems/LivePoemsTest.java | 66 +- .../fanar/json/jackson2/WireValueModule.java | 4 + .../reachability-metadata.json | 4 + .../json/jackson2/ChatRequestKnobsTest.java | 19 + .../fanar/json/jackson3/WireValueModule.java | 4 + .../reachability-metadata.json | 4 + .../json/jackson3/ChatRequestKnobsTest.java | 19 + .../qa/fanar/spring/ai/FanarChatModel.java | 83 + .../qa/fanar/spring/ai/FanarChatOptions.java | 400 ++ .../ai/FanarImageGenerationMetadata.java | 27 + .../qa/fanar/spring/ai/FanarImageModel.java | 21 +- .../qa/fanar/spring/ai/FanarImageOptions.java | 83 + .../spring/ai/FanarTextToSpeechModel.java | 37 +- .../spring/ai/FanarTextToSpeechOptions.java | 80 + .../fanar/spring/ai/FanarChatModelTest.java | 99 + .../fanar/spring/ai/FanarChatOptionsTest.java | 288 + .../fanar/spring/ai/FanarImageModelTest.java | 32 +- .../spring/ai/FanarImageOptionsTest.java | 41 + .../spring/ai/FanarTextToSpeechModelTest.java | 42 +- .../ai/FanarTextToSpeechOptionsTest.java | 41 + 83 files changed, 10310 insertions(+), 254 deletions(-) create mode 100644 api-spec/openapi.yaml create mode 100644 core/src/main/java/qa/fanar/core/FanarClientClosedRequestException.java create mode 100644 core/src/main/java/qa/fanar/core/audio/AvailableVoice.java create mode 100644 core/src/main/java/qa/fanar/core/audio/VoiceType.java create mode 100644 core/src/main/java/qa/fanar/core/chat/Madhab.java create mode 100644 core/src/main/java/qa/fanar/core/internal/audio/AudioStreamPublisher.java create mode 100644 core/src/main/java/qa/fanar/core/internal/transport/ErrorEnvelope.java create mode 100644 core/src/main/java/qa/fanar/core/internal/transport/StreamFlag.java create mode 100644 core/src/test/java/qa/fanar/core/audio/AvailableVoiceTest.java create mode 100644 core/src/test/java/qa/fanar/core/audio/VoiceTypeTest.java create mode 100644 core/src/test/java/qa/fanar/core/chat/MadhabTest.java create mode 100644 core/src/test/java/qa/fanar/core/internal/audio/AudioStreamPublisherTest.java create mode 100644 core/src/test/java/qa/fanar/core/internal/transport/ErrorEnvelopeTest.java create mode 100644 core/src/test/java/qa/fanar/core/internal/transport/StreamFlagTest.java create mode 100644 docs/adr/023-streaming-tts-via-flow-publisher.md create mode 100644 docs/adr/024-spring-ai-vendor-options.md create mode 100644 spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatOptions.java create mode 100644 spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageGenerationMetadata.java create mode 100644 spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageOptions.java create mode 100644 spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechOptions.java create mode 100644 spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatOptionsTest.java create mode 100644 spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageOptionsTest.java create mode 100644 spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechOptionsTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e85cbc9..53891db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,10 @@ jobs: docs/adr/020-spring-boot-4-starter.md \ docs/adr/021-spring-ai-2-adapter.md \ docs/adr/022-observability-compose-factory.md \ + docs/adr/023-streaming-tts-via-flow-publisher.md \ + docs/adr/024-spring-ai-vendor-options.md \ + api-spec/openapi.json \ + api-spec/openapi.yaml \ .github/pull_request_template.md \ .github/SECURITY.md; do if [ ! -f "$file" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d001b..6db2f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,66 @@ may break public API until 1.0.0 ships. ## [Unreleased] +### Added + +- **`fanar-core`** — streaming TTS: `AudioClient.speechStream(request)` returns + `Flow.Publisher` and delivers the audio chunked as the server generates it + (the wire `stream:true` mode, mp3 + wav). Single-subscriber, back-pressured, cancel closes + the connection — the same contract as chat streaming. + ([ADR-023](docs/adr/023-streaming-tts-via-flow-publisher.md)) +- **`fanar-spring-ai-starter`** — `FanarTextToSpeechModel.stream(...)` now streams for real: + one `TextToSpeechResponse` per audio chunk via `speechStream`, replacing the previous + single-element-Flux emulation. +- **`fanar-core`** — audio: `Voice.ABDULRAHMAN` and `Voice.RADWA` (the two emotion-capable + built-ins), `TextToSpeechRequest.withEmotion` (emotional synthesis — `Fanar-Aura-TTS-2` + + emotion-capable voices only, otherwise HTTP 422) plus a fluent + `TextToSpeechRequest.builder()`, and the rich voice catalogue types `AvailableVoice` / + `VoiceType` returned by `listVoices()`. +- **`fanar-core`** — `ChatModel.FANAR_SADIQ_2` (madhab-aware Islamic RAG, extra authorization + required) plus two new `ChatRequest` fields: `persona` (custom assistant voice/identity, + `Fanar-Sadiq` only, ≤ 2000 chars) and `madhab` (list of the new open value class `Madhab`: + `ALL` / `HANAFI` / `MALIKI` / `SHAFII` / `HANBALI`, honoured by `Fanar-Sadiq-2`). + Both codecs serialize `Madhab` via their wire-value modules. +- **`fanar-core`** — images: `ImageGenerationRequest.revise` (server default **true** — automatic + prompt revision for style/quality/cultural alignment; pass `false` to keep the prompt verbatim). +- **`fanar-spring-ai-starter`** — `FanarImageGenerationMetadata(revised, revisedPrompt)` attached + to every `ImageGeneration`, and the previously-dropped `created` timestamp now fills + `ImageResponseMetadata`. +- **`fanar-spring-ai-starter`** — vendor options + ([ADR-024](docs/adr/024-spring-ai-vendor-options.md)): `FanarChatOptions` (persona, madhab, + thinking mode, Islamic-RAG scoping, logit bias, and the vLLM sampling knobs — all previously + unreachable through portable `ChatOptions`), `FanarTextToSpeechOptions` (`withEmotion`, + `quranReciter`), and `FanarImageOptions` (`revise`). `FanarChatOptions.Builder` extends + Spring AI's `DefaultChatOptionsBuilder`, so the extras survive the `ChatClient` + `mutate()`/`combineWith()` pipeline. +- **`fanar-core`** — `ErrorCode.CLIENT_CLOSED_REQUEST` and `FanarClientClosedRequestException` + (HTTP 499, `client_closed_request`), which the 2026-08 Fanar spec declares on every endpoint. + Correctly classified as non-retryable; previously a 499 fell into the generic 5xx fallback and + was retried. + +### Changed + +- **`fanar-core`** — `ExceptionMapper` now parses the Fanar error envelope and routes by the typed + `error.code` first, falling back to HTTP status for non-envelope bodies. `FanarQuotaExceededException` + is now reachable (previously every HTTP 429 surfaced as `FanarRateLimitException`), and a + non-filter 400 no longer surfaces as `FanarContentFilterException`. Exception messages now carry + the envelope's `message` instead of the raw JSON body when available. + ([ADR-006 amendment](docs/adr/006-unchecked-exception-hierarchy.md)) +- **Breaking** — `ImageGenerationItem` is now + `(String b64Json, boolean revised, String revisedPrompt)` (was single-component), matching the + spec's now-required response fields. +- **Breaking** — `VoiceResponse.voices()` is now `List` (was `List`), + matching the 2026-08 spec's rich voice objects; the listing now always includes the built-in + public voices, not only personalized ones. Use `AvailableVoice.name()` where the raw string + was used before. +- **Breaking** — `FanarClientException` gained a ninth permitted subtype + (`FanarClientClosedRequestException`). Exhaustive `switch` expressions over the leaves of + `FanarClientException` no longer compile until the new case is added; switches over the four + top-level `FanarException` branches are unaffected. Allowed pre-1.0 per + [ADR-019](docs/adr/019-pre-10-stability-policy.md). +- **Spec** — `api-spec/openapi.json` refreshed to the 2026-08-05 Fanar spec; added + `api-spec/openapi.yaml`, its YAML twin (JSON remains normative). + ## [0.1.0] - 2026-04-28 Initial public release. Pre-1.0; not yet on Maven Central — install via `./mvnw install` diff --git a/README.md b/README.md index 33f4922..2566ef9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ Java SDK for [Fanar](https://fanar.qa) — Qatar's Arabic-centric multimodal AI > **Status:** pre-1.0. The core SDK and every Fanar domain (chat, audio, images, translations, > moderations, tokens, models, poems) are implemented with 100 % JaCoCo coverage and battle-tested -> against the live API. Spring Boot 4 and Spring AI 2.0 starters ship with a sample app each. +> against the live API, tracking the 2026-08 Fanar spec: madhab-aware `Fanar-Sadiq-2`, custom +> personas, streamed + emotional TTS, rich voice catalogue, and culturally-aligned image prompt +> revision. Spring Boot 4 and Spring AI 2.0 starters ship with a sample app each. > Not yet on Maven Central — install via `./mvnw install` for now. ## Why this SDK? @@ -36,12 +38,12 @@ Three install paths depending on your stack. qa.fanar fanar-core - 0.1.0-SNAPSHOT + 0.2.0-SNAPSHOT qa.fanar fanar-json-jackson3 - 0.1.0-SNAPSHOT + 0.2.0-SNAPSHOT ``` @@ -62,7 +64,7 @@ try (FanarClient client = FanarClient.builder().apiKey(System.getenv("FANAR_API_ qa.fanar fanar-spring-boot-4-starter - 0.1.0-SNAPSHOT + 0.2.0-SNAPSHOT ``` @@ -86,7 +88,7 @@ class MyController { qa.fanar fanar-spring-ai-starter - 0.1.0-SNAPSHOT + 0.2.0-SNAPSHOT ``` @@ -131,4 +133,4 @@ ChatClient chatClient(ChatModel model, ChatMemory memory) { // Spring AI typ - [ADRs](docs/adr/INDEX.md) — non-obvious design decisions. - [Library best practices](docs/JAVA_LIBRARY_BEST_PRACTICES.md) — internal hygiene rules. - [Contributing](docs/CONTRIBUTING.md) — workflow, conventions. -- [Fanar OpenAPI spec](api-spec/openapi.json) — the wire contract we model. +- [Fanar OpenAPI spec](api-spec/openapi.json) — the wire contract we model (normative; [YAML twin](api-spec/openapi.yaml) provided for convenience). diff --git a/api-spec/openapi.json b/api-spec/openapi.json index a38d9fb..2ae78fb 100644 --- a/api-spec/openapi.json +++ b/api-spec/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Fanar API", - "description": "You can interact with FanarAPI for seamless chat completion and text processing using Fanar.
Base URL: https://api.fanar.qa
Request API Access: https://api.fanar.qa/request

Rate Limits

\n

To ensure fair usage and optimal performance for all users, our API has rate limits in place. When you exceed a rate limit, your request will typically receive a 429 Too Many Requests HTTP status code.

\n

The limits detailed below are our default settings for all models. We understand that your needs may vary. If your application requires higher throughput or more tailored rate limits for specific models, please contact us:

\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ModelRate Limit
Fanar50 requests/minute
Fanar-S-1-7B50 requests/minute
Fanar-C-1-8.7B50 requests/minute
Fanar-C-2-27B50 requests/minute
Fanar-Sadiq50 requests/minute
Fanar-Sadiq-TTS-120 requests/day
Fanar-Oryx-IVU-220 requests/day
Fanar-Aura-TTS-220 requests/day
Fanar-Aura-STT-120 requests/day
Fanar-Aura-STT-LF-110 requests/day
Fanar-Oryx-IG-220 requests/day
Fanar-Guard-250 requests/minute
Fanar-Shaheen-MT-120 requests/day
Fanar-Diwan50 requests/minute
\n", + "description": "You can interact with FanarAPI for seamless chat completion and text processing using Fanar.
Base URL: https://api.fanar.qa
Request API Access: https://api.fanar.qa/request

Rate Limits

\n

To ensure fair usage and optimal performance for all users, our API has rate limits in place. When you exceed a rate limit, your request will typically receive a 429 Too Many Requests HTTP status code.

\n

The limits detailed below are our default settings for all models. We understand that your needs may vary. If your application requires higher throughput or more tailored rate limits for specific models, please contact us:

\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ModelRate Limit
Fanar50 requests/minute
Fanar-S-1-7B50 requests/minute
Fanar-C-1-8.7B50 requests/minute
Fanar-C-2-27B50 requests/minute
Fanar-Sadiq50 requests/minute
Fanar-Sadiq-250 requests/minute
Fanar-Sadiq-TTS-120 requests/day
Fanar-Oryx-IVU-220 requests/day
Fanar-Aura-TTS-220 requests/day
Fanar-Aura-STT-120 requests/day
Fanar-Aura-STT-LF-110 requests/day
Fanar-Oryx-IG-220 requests/day
Fanar-Guard-250 requests/minute
Fanar-Shaheen-MT-120 requests/day
Fanar-Diwan50 requests/minute
\n", "termsOfService": "https://fanar.qa/terms-of-services", "contact": { "name": "Fanar Support", @@ -224,6 +224,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -300,7 +317,17 @@ { "lang": "Python - Fanar-Sadiq", "label": "Python - Fanar-Sadiq", - "source": "import requests\n\ndef model_api(messages, model):\n headers = {\n \"Authorization\": \"Bearer YOUR_API_KEY_HERE\",\n \"Content-Type\": \"application/json\",\n }\n\n payload = {\n \"model\": model,\n \"messages\": messages,\n \"max_tokens\": 750,\n }\n\n response = requests.post(\n \"https://api.fanar.qa/v1/chat/completions\", \n json=payload, \n headers=headers\n )\n return response.json()\n\nmodel = \"Fanar-Sadiq\"\n\nprompt = \"What are the Islamic values?\"\n\nmessages = [\n {\"role\": \"user\", \"content\": prompt}\n]\n\nresponse = model_api(messages=messages, model=model)\n\n# Extract the assistant's answer\ncontent = response[\"choices\"][0][\"message\"][\"content\"]\nprint(\"Assistant Response:\\n\")\nprint(content)\n\n# Print reference sources if present\nreferences = response[\"choices\"][0][\"message\"].get(\"references\", [])\nif references:\n print(\"\\nReferences:\")\n for ref in references:\n number = ref.get(\"number\", \"-\")\n source = ref.get(\"source\", \"Unknown source\")\n ref_content = ref.get(\"content\", \"\")\n print(f\"\\n[{number}] {source}\\n{ref_content}\")\nelse:\n print(\"\\nNo references returned.\")\n \n" + "source": "import requests\n\ndef model_api(messages, model):\n headers = {\n \"Authorization\": \"Bearer YOUR_API_KEY_HERE\",\n \"Content-Type\": \"application/json\",\n }\n\n payload = {\n \"model\": model,\n \"messages\": messages,\n \"max_tokens\": 750,\n }\n\n response = requests.post(\n \"https://api.fanar.qa/v1/chat/completions\", \n json=payload, \n headers=headers\n )\n return response.json()\n\nmodel = \"Fanar-Sadiq\"\n\nprompt = \"What are the Islamic values?\"\n\nmessages = [\n {\"role\": \"user\", \"content\": prompt}\n]\n\nresponse = model_api(messages=messages, model=model)\n\n# Extract the assistant's answer\ncontent = response[\"choices\"][0][\"message\"][\"content\"]\nprint(\"Assistant Response:\\n\")\nprint(content)\n\n# Print reference sources if present\nreferences = response[\"choices\"][0][\"message\"].get(\"references\", [])\nif references:\n print(\"\\nReferences:\")\n for ref in references:\n number = ref.get(\"number\", \"-\")\n source = ref.get(\"source\", \"Unknown source\")\n ref_content = ref.get(\"content\", \"\")\n print(f\"\\n[{number}] {source}\\n{ref_content}\")\nelse:\n print(\"\\nNo references returned.\")\n" + }, + { + "lang": "Python - Fanar-Sadiq with persona", + "label": "Python - Fanar-Sadiq with persona", + "source": "# The \"persona\" parameter controls the assistant's voice and identity.\n# It is only supported for the Fanar-Sadiq model.\n\nimport requests\n\ndef model_api(messages, persona=None):\n headers = {\n \"Authorization\": \"Bearer YOUR_API_KEY_HERE\",\n \"Content-Type\": \"application/json\",\n }\n\n payload = {\n \"model\": \"Fanar-Sadiq\",\n \"messages\": messages,\n \"max_tokens\": 750,\n }\n if persona:\n payload[\"persona\"] = persona\n\n response = requests.post(\n \"https://api.fanar.qa/v1/chat/completions\",\n json=payload,\n headers=headers,\n )\n return response.json()\n\nmessages = [\n {\"role\": \"user\", \"content\": \"What are the Islamic values?\"}\n]\n\n# Customize the assistant's voice/identity for this call.\npersona = \"You are a warm, patient teacher who explains concepts simply for young students.\"\n\nresponse = model_api(messages=messages, persona=persona)\n\ncontent = response[\"choices\"][0][\"message\"][\"content\"]\nprint(\"Assistant Response:\\n\")\nprint(content)\n\n# Print reference sources if present\nreferences = response[\"choices\"][0][\"message\"].get(\"references\", [])\nif references:\n print(\"\\nReferences:\")\n for ref in references:\n number = ref.get(\"number\", \"-\")\n source = ref.get(\"source\", \"Unknown source\")\n ref_content = ref.get(\"content\", \"\")\n print(f\"\\n[{number}] {source}\\n{ref_content}\")\nelse:\n print(\"\\nNo references returned.\")\n" + }, + { + "lang": "Python - Fanar-Sadiq-2", + "label": "Python - Fanar-Sadiq-2", + "source": "# Fanar-Sadiq-2 requires additional authorization and is not allowed by default.\n\nimport requests\n\ndef model_api(messages, madhab=None):\n headers = {\n \"Authorization\": \"Bearer YOUR_API_KEY_HERE\",\n \"Content-Type\": \"application/json\",\n }\n\n payload = {\n \"model\": \"Fanar-Sadiq-2\",\n \"messages\": messages,\n }\n if madhab:\n payload[\"madhab\"] = madhab\n\n response = requests.post(\n \"https://api.fanar.qa/v1/chat/completions\",\n json=payload,\n headers=headers,\n )\n return response.json()\n\nmessages = [\n {\"role\": \"user\", \"content\": \"What are the conditions for Zakat on gold according to the Hanafi school?\"}\n]\n\nresponse = model_api(messages=messages, madhab=[\"hanafi\"])\n\ncontent = response[\"choices\"][0][\"message\"][\"content\"]\nprint(\"Assistant Response:\\n\")\nprint(content)\n\n# Print reference sources if present\nreferences = response[\"choices\"][0][\"message\"].get(\"references\", [])\nif references:\n print(\"\\nReferences:\")\n for ref in references:\n number = ref.get(\"number\", \"-\")\n source = ref.get(\"source\", \"Unknown source\")\n ref_content = ref.get(\"content\", \"\")\n print(f\"\\n[{number}] {source}\\n{ref_content}\")\n" }, { "lang": "Python - Thinking mode (Fanar-C-1-8.7B)", @@ -326,7 +353,7 @@ "Audio" ], "summary": "Create Speech", - "description": "This endpoint is compatible with the OpenAI library.
Generates audio from the input text.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "This endpoint is compatible with the OpenAI library.
Generates audio from the input text.", "operationId": "create_speech_v1_audio_speech_post", "requestBody": { "content": { @@ -529,6 +556,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -605,7 +649,22 @@ { "lang": "Python - requests for Quranic text", "label": "Python - requests for Quranic text", - "source": "# Text-to-Speech requires additional authorization and is not allowed by default.\n\nimport requests\n\nurl = \"https://api.fanar.qa/v1/audio/speech\"\nheaders = {\n \"Authorization\": \"Bearer YOUR_API_KEY\",\n \"Content-Type\": \"application/json\"\n}\ndata = {\n \"model\": \"Fanar-Sadiq-TTS-1\",\n \"input\": \"Quranic text goes here\",\n \"voice\": \"Amelia\",\n \"quran_reciter\": \"abdul-basit\",\n \"response_format\": \"mp3\"\n}\n\nresponse = requests.post(url, headers=headers, json=data)\n\nrevised_input = response.headers.get('X-Revised-Input')\nif revised_input:\n from urllib.parse import unquote\n print(\"Revised Input:\", unquote(revised_input))\n\nwith open(\"quranic_speech.mp3\", \"wb\") as f:\n f.write(response.content)" + "source": "# Text-to-Speech requires additional authorization and is not allowed by default.\n\nimport requests\n\nurl = \"https://api.fanar.qa/v1/audio/speech\"\nheaders = {\n \"Authorization\": \"Bearer YOUR_API_KEY\",\n \"Content-Type\": \"application/json\"\n}\ndata = {\n \"model\": \"Fanar-Sadiq-TTS-1\",\n \"input\": \"Quranic text goes here\",\n \"voice\": \"Amelia\",\n \"quran_reciter\": \"abdul-basit\",\n \"response_format\": \"mp3\"\n}\n\nresponse = requests.post(url, headers=headers, json=data)\n\nrevised_input = response.headers.get('X-Revised-Input')\nif revised_input:\n from urllib.parse import unquote\n print(\"Revised Input:\", unquote(revised_input))\n\nwith open(\"quranic_speech.mp3\", \"wb\") as f:\n f.write(response.content)\n" + }, + { + "lang": "cURL - streaming", + "label": "cURL - streaming", + "source": "# Pass \"stream\": true to receive audio bytes progressively as they are\n# synthesized. The response Content-Type is identical to the non-streaming\n# case (audio/wav or audio/mpeg) but uses chunked transfer encoding, so\n# `--output greeting.mp3` keeps working. The first byte arrives much\n# sooner for longer inputs.\n\ncurl -X POST \"https://api.fanar.qa/v1/audio/speech\" \\\n-H \"Content-Type: application/json\" \\\n-H \"Authorization: Bearer YOUR_API_KEY\" \\\n--no-buffer \\\n--output greeting.mp3 \\\n-d '{\n \"model\": \"Fanar-Aura-TTS-2\",\n \"input\": \"Hello! I hope you are having a wonderful day.\",\n \"voice\": \"Amelia\",\n \"response_format\": \"mp3\",\n \"stream\": true\n}'\n" + }, + { + "lang": "Python - OpenAI streaming", + "label": "Python - OpenAI streaming", + "source": "# Text-to-Speech requires additional authorization and is not allowed by default.\n# Use the OpenAI SDK's with_streaming_response helper to play audio as it\n# arrives. `stream: true` is passed via `extra_body` since it's a\n# Fanar-specific extension to the OpenAI-compatible schema.\n\nfrom openai import OpenAI\n\nclient = OpenAI(\n base_url=\"https://api.fanar.qa/v1\",\n api_key=\"YOUR_API_KEY\"\n)\n\nwith client.audio.speech.with_streaming_response.create(\n model=\"Fanar-Aura-TTS-2\",\n input=\"Hello! I hope you are having a wonderful day.\",\n voice=\"Amelia\",\n response_format=\"wav\",\n extra_body={\"stream\": True},\n) as response:\n response.stream_to_file(\"greeting.wav\")\n" + }, + { + "lang": "Python - requests streaming", + "label": "Python - requests streaming", + "source": "# Text-to-Speech requires additional authorization and is not allowed by default.\n# Pass stream=True to requests so it doesn't pre-buffer the body, then\n# iterate over chunks. The first chunk arrives within ~1 s even for long\n# inputs that would otherwise wait many seconds for the full synthesis.\n\nimport requests\n\nurl = \"https://api.fanar.qa/v1/audio/speech\"\nheaders = {\n \"Authorization\": \"Bearer YOUR_API_KEY\",\n \"Content-Type\": \"application/json\"\n}\ndata = {\n \"model\": \"Fanar-Aura-TTS-2\",\n \"input\": \"Hello! I hope you are having a wonderful day.\",\n \"voice\": \"Amelia\",\n \"response_format\": \"wav\",\n \"stream\": True\n}\n\nwith requests.post(url, headers=headers, json=data, stream=True) as r:\n r.raise_for_status()\n with open(\"greeting.wav\", \"wb\") as f:\n for chunk in r.iter_content(chunk_size=8192):\n if chunk:\n f.write(chunk)" } ] } @@ -616,7 +675,7 @@ "Audio" ], "summary": "Create Transcription", - "description": "This endpoint is compatible with the OpenAI library.
Transcribes audio into the input language.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "This endpoint is compatible with the OpenAI library.
Transcribes audio into the input language.", "operationId": "create_transcription_v1_audio_transcriptions_post", "requestBody": { "content": { @@ -805,6 +864,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -892,11 +968,11 @@ "Audio" ], "summary": "List Voices", - "description": "Lists the personalized voices.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "Lists all available text-to-speech voices.
The list will include all built-in (public) voices by default. If your API key is authorized to create personalized voices, they will be included in the list and labeled as type: \"personal\".", "operationId": "list_voices_v1_audio_voices_get", "responses": { "200": { - "description": "A list of personalized voices.", + "description": "A list of available voices.", "content": { "application/json": { "schema": { @@ -904,8 +980,34 @@ }, "example": { "voices": [ - "Jenny", - "Dora" + { + "name": "Amelia", + "name_ar": "أميليا", + "gender": "Female", + "accent": "British", + "languages": [ + "en" + ], + "type": "public", + "emotion": false + }, + { + "name": "Hamad", + "name_ar": "حمد", + "gender": "Male", + "accent": "Gulf", + "languages": [ + "ar" + ], + "type": "public", + "emotion": false + }, + { + "name": "MyVoice", + "languages": [], + "type": "personal", + "emotion": false + } ] } } @@ -1066,6 +1168,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -1141,7 +1260,7 @@ "Audio" ], "summary": "Create Voice", - "description": "Create a personalized voice that can be used to generate speech.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "Creates a personalized voice that can be used to generate speech.
The voice name must be unique among your own personalized voices. Names that match a built-in (public) voice are allowed — public voices and your personalized voices live in separate namespaces. When you request a voice name that exists in both, your personalized voice takes precedence at synthesis time in POST /v1/audio/speech.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", "operationId": "create_voice_v1_audio_voices_post", "requestBody": { "content": { @@ -1317,6 +1436,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -1576,6 +1712,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -1648,7 +1801,7 @@ "Images" ], "summary": "Create Image", - "description": "This endpoint is compatible with the OpenAI library.
Creates an image given a prompt.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "This endpoint is compatible with the OpenAI library.
Creates an image given a prompt.", "operationId": "create_image_v1_images_generations_post", "requestBody": { "content": { @@ -1826,6 +1979,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -1908,7 +2078,7 @@ "Translations" ], "summary": "Translate", - "description": "Translate the given text into the specified language.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "Translate the given text into the specified language.", "operationId": "translate_v1_translations_post", "requestBody": { "content": { @@ -2086,6 +2256,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -2163,7 +2350,7 @@ "Poems" ], "summary": "Create Poem", - "description": "This endpoint is compatible with the OpenAI library.
Creates a poem given a prompt.
This endpoint requires additional authorization and is not allowed by default. Please contact support@fanar.qa.", + "description": "This endpoint is compatible with the OpenAI library.
Creates a poem given a prompt.", "operationId": "create_poem_v1_poems_generations_post", "requestBody": { "content": { @@ -2341,6 +2528,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -2577,6 +2781,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -2812,6 +3033,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -3057,6 +3295,23 @@ } } }, + "499": { + "description": "Client closed request before completion", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "client_closed_request", + "message": "Client closed request before completion", + "status": 499 + } + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -4239,6 +4494,7 @@ "Fanar-C-1-8.7B", "Fanar-C-2-27B", "Fanar-Sadiq", + "Fanar-Sadiq-2", "Fanar-Oryx-IVU-2" ], "title": "ChatCompletionLLM" @@ -4270,7 +4526,7 @@ }, "model": { "$ref": "#/components/schemas/ChatCompletionLLM", - "description": "The model to use for the completion. For the Fanar-Sadiq model, the following LLM parameters are not used.
Islamic-RAG is replaced with Fanar-Sadiq and will be removed soon. Please use Fanar-Sadiq instead." + "description": "The model to use for the completion. For the Fanar-Sadiq and Fanar-Sadiq-2 models, the following LLM parameters are not used." }, "enable_thinking": { "anyOf": [ @@ -4670,6 +4926,21 @@ "title": "Filter Sources", "description": "List of sources to filter from the Fanar-Sadiq model. Accepts predefined source names or values starting with 'digital_seerah'." }, + "madhab": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MadhabEnum" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Madhab", + "description": "List of madhab (Islamic school of thought) to filter by for the Fanar-Sadiq-2 model." + }, "restrict_to_islamic": { "anyOf": [ { @@ -4682,6 +4953,19 @@ "title": "Restrict To Islamic", "description": "When enabled for Fanar-Sadiq model, only Islamic content prompts will be accepted. Non-Islamic content will be rejected.", "default": false + }, + "persona": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ], + "title": "Persona", + "description": "Custom persona that controls the assistant's voice and identity for the Fanar-Sadiq model. Free-form text; only supported for Fanar-Sadiq." } }, "type": "object", @@ -5229,7 +5513,8 @@ "unprocessable", "conflict", "Not found", - "no_longer_supported" + "no_longer_supported", + "client_closed_request" ], "title": "ErrorCode" }, @@ -5257,7 +5542,8 @@ 422, 409, 404, - 410 + 410, + 499 ], "title": "ErrorStatus" }, @@ -5274,11 +5560,13 @@ }, "revised": { "type": "boolean", - "title": "Revised" + "title": "Revised", + "description": "Indicates whether the prompt was revised before generation." }, "revised_prompt": { "type": "string", - "title": "Revised Prompt" + "title": "Revised Prompt", + "description": "The prompt used for generation, which may be revised from the original prompt." } }, "type": "object", @@ -5296,11 +5584,23 @@ "type": "string", "title": "B64 Json", "description": "The base64-encoded JSON of the generated image." + }, + "revised": { + "type": "boolean", + "title": "Revised", + "description": "Indicates whether the prompt was revised before generation." + }, + "revised_prompt": { + "type": "string", + "title": "Revised Prompt", + "description": "The prompt used for generation, which may be revised from the original prompt." } }, "type": "object", "required": [ - "b64_json" + "b64_json", + "revised", + "revised_prompt" ], "title": "ImageGenerationItem" }, @@ -5321,6 +5621,12 @@ "type": "string", "title": "Prompt", "description": "A text description of the desired image." + }, + "revise": { + "type": "boolean", + "title": "Revise", + "description": "Whether to automatically revise the prompt to enhance style, quality, and cultural alignment for improved generation results.", + "default": true } }, "type": "object", @@ -5394,6 +5700,17 @@ ], "title": "LLM" }, + "MadhabEnum": { + "type": "string", + "enum": [ + "all", + "hanafi", + "maliki", + "shafii", + "hanbali" + ], + "title": "MadhabEnum" + }, "ModelObject": { "type": "string", "enum": [ @@ -5769,8 +6086,9 @@ "voice": { "type": "string", "title": "Voice", - "description": "The voice to use for the text-to-speech. Details are below:\n| **Voice** | Gender | Accent | Supported Languages |\n|----------------------|--------|----------|---------------------|\n| Amelia | Female | British | [English](https://chat.fanar.qa/api/sample-voices/en/Amelia) |\n| Emily | Female | American | [English](https://chat.fanar.qa/api/sample-voices/en/Emily) |\n| Hamad | Male | Gulf | [Arabic](https://chat.fanar.qa/api/sample-voices/ar/Hamad) |\n| Harry | Male | British | [English](https://chat.fanar.qa/api/sample-voices/en/Harry) |\n| Huda | Female | Standard | [Arabic](https://chat.fanar.qa/api/sample-voices/ar/Huda) |\n| Jake | Male | American | [English](https://chat.fanar.qa/api/sample-voices/en/Jake) |\n| Jasim | Male | Gulf | [Arabic](https://chat.fanar.qa/api/sample-voices/ar/Jasim) |\n| Noor | Female | Standard | [Arabic](https://chat.fanar.qa/api/sample-voices/ar/Noor) |\n", + "description": "The voice to use for the text-to-speech. Details are below:\n
VoiceGenderSupported LanguagesAccentEmotion
AbdulrahmanMaleArabicStandard
AmeliaFemaleEnglishBritish
EmilyFemaleEnglishAmerican
HamadMaleArabicStandard
HarryMaleEnglishBritish
HudaFemaleArabicStandard
JakeMaleEnglishAmerican
JasimMaleArabicStandard
NoorFemaleArabicStandard
RadwaFemaleArabicStandard
", "enum": [ + "Abdulrahman", "Amelia", "Emily", "Hamad", @@ -5778,7 +6096,8 @@ "Huda", "Jake", "Jasim", - "Noor" + "Noor", + "Radwa" ] }, "response_format": { @@ -5790,6 +6109,18 @@ "$ref": "#/components/schemas/QuranReciters", "description": "The Quran reciter to use when using Fanar-Sadiq-TTS-1 model for Quranic text.", "default": "abdul-basit" + }, + "with_emotion": { + "type": "boolean", + "title": "With Emotion", + "description": "Enable emotional speech synthesis. **Only applicable to `Fanar-Aura-TTS-2` and to voices where `emotion: true` in GET /v1/voices.** When the selected voice does not support emotion, or when used with `Fanar-Sadiq-TTS-1`, the request is rejected with a 422 error. Defaults to false.", + "default": false + }, + "stream": { + "type": "boolean", + "title": "Stream", + "description": "Stream the audio as it is generated. Supported for both `wav` and `mp3`.", + "default": false } }, "type": "object", @@ -5995,22 +6326,162 @@ ], "title": "VideoURL" }, + "Voice": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The English name of the voice. This is the identifier passed to the TTS endpoint.", + "examples": [ + "Amelia" + ] + }, + "name_ar": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name Ar", + "description": "The Arabic display name of the voice, when available.", + "examples": [ + "أميليا" + ] + }, + "gender": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gender", + "description": "Gender label of the voice (e.g., 'Male', 'Female').", + "examples": [ + "Female" + ] + }, + "accent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accent", + "description": "Accent label of the voice (e.g., 'British', 'Gulf', 'American', 'Standard').", + "examples": [ + "British" + ] + }, + "languages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Languages", + "description": "Supported language codes (e.g., 'en', 'ar').", + "examples": [ + [ + "en" + ] + ] + }, + "type": { + "type": "string", + "enum": [ + "public", + "personal" + ], + "title": "Type", + "description": "Whether this is a built-in public voice or a personalized voice registered for this API key.", + "examples": [ + "public" + ] + }, + "emotion": { + "type": "boolean", + "title": "Emotion", + "description": "Whether this voice supports emotional speech synthesis. When true, you may set `with_emotion: true` on POST /v1/audio/speech to enable emotional rendering.", + "default": false, + "examples": [ + true + ] + } + }, + "type": "object", + "required": [ + "name", + "type" + ], + "title": "Voice", + "example": { + "accent": "British", + "emotion": false, + "gender": "Female", + "languages": [ + "en" + ], + "name": "Amelia", + "name_ar": "أميليا", + "type": "public" + } + }, "VoiceResponse": { "properties": { "voices": { "items": { - "type": "string" + "$ref": "#/components/schemas/Voice" }, "type": "array", "title": "Voices", - "description": "A list of personalized voices." + "description": "Available voices. Always includes the built-in public voices. Includes personalized voices registered for this API key when voice personalization is authorized." } }, "type": "object", "required": [ "voices" ], - "title": "VoiceResponse" + "title": "VoiceResponse", + "example": { + "voices": [ + { + "accent": "British", + "emotion": false, + "gender": "Female", + "languages": [ + "en" + ], + "name": "Amelia", + "name_ar": "أميليا", + "type": "public" + }, + { + "accent": "Gulf", + "emotion": false, + "gender": "Male", + "languages": [ + "ar" + ], + "name": "Hamad", + "name_ar": "حمد", + "type": "public" + }, + { + "emotion": false, + "languages": [], + "name": "MyVoice", + "type": "personal" + } + ] + } }, "TokenChunk": { "properties": { @@ -6654,4 +7125,4 @@ "description": "All API endpoints require a **Bearer** token. Include it in the `Authorization` header:\n\n```\nAuthorization: Bearer YOUR_API_KEY\n```" } ] -} \ No newline at end of file +} diff --git a/api-spec/openapi.yaml b/api-spec/openapi.yaml new file mode 100644 index 0000000..ac66e99 --- /dev/null +++ b/api-spec/openapi.yaml @@ -0,0 +1,6043 @@ +openapi: 3.1.0 +info: + title: Fanar API + description: > + You can interact with FanarAPI for seamless chat completion and text + processing using Fanar.
Base URL: + https://api.fanar.qa
Request API Access: https://api.fanar.qa/request

Rate Limits

+ +

To ensure fair usage and optimal performance for all users, our API has + rate limits in place. When you exceed a rate limit, your request will + typically receive a 429 Too Many Requests HTTP status code.

+ +

The limits detailed below are our default settings for all models. We + understand that your needs may vary. If your application requires higher + throughput or more tailored rate limits for specific models, please contact + us:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelRate Limit
Fanar50 requests/minute
Fanar-S-1-7B50 requests/minute
Fanar-C-1-8.7B50 requests/minute
Fanar-C-2-27B50 requests/minute
Fanar-Sadiq50 requests/minute
Fanar-Sadiq-250 requests/minute
Fanar-Sadiq-TTS-120 requests/day
Fanar-Oryx-IVU-220 requests/day
Fanar-Aura-TTS-220 requests/day
Fanar-Aura-STT-120 requests/day
Fanar-Aura-STT-LF-110 requests/day
Fanar-Oryx-IG-220 requests/day
Fanar-Guard-250 requests/minute
Fanar-Shaheen-MT-120 requests/day
Fanar-Diwan50 requests/minute
+ termsOfService: https://fanar.qa/terms-of-services + contact: + name: Fanar Support + url: https://fanar.qa/ + email: support@fanar.qa + version: 1.0.0 + x-logo: + url: /static/white-logo.svg + alt: logo +paths: + /v1/chat/completions: + post: + tags: + - Chat + summary: Create Chat Completion + description: This endpoint is compatible with the OpenAI library. If certain + parameters are not supported by the OpenAI library, they can be provided + in the `extra_body` field of the OpenAI request.
When creating the + OpenAI object, set the `base_url` as the API domain followed by `/v1`. + operationId: create_chat_completion_v1_chat_completions_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionRequest" + required: true + responses: + "200": + description: Chat completion response + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionResponse" + text/event-stream: + schema: + oneOf: + - $ref: "#/components/schemas/TokenChunk" + - $ref: "#/components/schemas/ToolCallChunk" + - $ref: "#/components/schemas/ToolResultChunk" + - $ref: "#/components/schemas/ProgressChunk" + - $ref: "#/components/schemas/DoneChunk" + - $ref: "#/components/schemas/ErrorChunk" + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY_HERE" \ + -d '{ + "model": "Fanar", + "messages": [ + { + "role": "user", + "content": "Your message here" + } + ] + }' + - lang: Python + label: Python - OpenAI + source: | + from openai import OpenAI + + client = OpenAI( + base_url="https://api.fanar.qa/v1", + api_key="YOUR_API_KEY_HERE", + ) + + model_name = "Fanar" + messages = [ + {"role": "user", "content": "Your message here"} + ] + + response = client.chat.completions.create( + model=model_name, + messages=messages, + ) + + print("Assistant Response:\n") + print(response.choices[0].message.content) + - lang: Python - OpenAI Stream + label: Python - OpenAI Stream + source: | + from openai import AsyncOpenAI + + client = AsyncOpenAI( + base_url="https://api.fanar.qa/v1", + api_key="YOUR_API_KEY_HERE", + ) + + messages = [ + {"role": "user", "content": "Your message here"} + ] + + stream = await client.chat.completions.create( + model="Fanar", + messages=messages, + stream=True + ) + + print("Assistant Response:\n") + + content = "" + references = None + + async for chunk in stream: + if chunk.choices and chunk.choices[0].delta: + delta = chunk.choices[0].delta + + # Capture references from the first chunk + if hasattr(delta, 'references') and delta.references: + references = delta.references + + # Stream content as it arrives + if delta.content: + content += delta.content + print(delta.content, end="", flush=True) + + print("\n") + + # Print references if present + if references: + print("\nReferences:") + for ref in references: + number = ref.get("number", "-") + source = ref.get("source", "Unknown source") + ref_content = ref.get("content", "") + print(f"\n[{number}] {source}\n{ref_content}") + - lang: Python - Fanar-Sadiq + label: Python - Fanar-Sadiq + source: | + import requests + + def model_api(messages, model): + headers = { + "Authorization": "Bearer YOUR_API_KEY_HERE", + "Content-Type": "application/json", + } + + payload = { + "model": model, + "messages": messages, + "max_tokens": 750, + } + + response = requests.post( + "https://api.fanar.qa/v1/chat/completions", + json=payload, + headers=headers + ) + return response.json() + + model = "Fanar-Sadiq" + + prompt = "What are the Islamic values?" + + messages = [ + {"role": "user", "content": prompt} + ] + + response = model_api(messages=messages, model=model) + + # Extract the assistant's answer + content = response["choices"][0]["message"]["content"] + print("Assistant Response:\n") + print(content) + + # Print reference sources if present + references = response["choices"][0]["message"].get("references", []) + if references: + print("\nReferences:") + for ref in references: + number = ref.get("number", "-") + source = ref.get("source", "Unknown source") + ref_content = ref.get("content", "") + print(f"\n[{number}] {source}\n{ref_content}") + else: + print("\nNo references returned.") + - lang: Python - Fanar-Sadiq with persona + label: Python - Fanar-Sadiq with persona + source: > + # The "persona" parameter controls the assistant's voice and + identity. + + # It is only supported for the Fanar-Sadiq model. + + + import requests + + + def model_api(messages, persona=None): + headers = { + "Authorization": "Bearer YOUR_API_KEY_HERE", + "Content-Type": "application/json", + } + + payload = { + "model": "Fanar-Sadiq", + "messages": messages, + "max_tokens": 750, + } + if persona: + payload["persona"] = persona + + response = requests.post( + "https://api.fanar.qa/v1/chat/completions", + json=payload, + headers=headers, + ) + return response.json() + + messages = [ + {"role": "user", "content": "What are the Islamic values?"} + ] + + + # Customize the assistant's voice/identity for this call. + + persona = "You are a warm, patient teacher who explains concepts + simply for young students." + + + response = model_api(messages=messages, persona=persona) + + + content = response["choices"][0]["message"]["content"] + + print("Assistant Response:\n") + + print(content) + + + # Print reference sources if present + + references = response["choices"][0]["message"].get("references", []) + + if references: + print("\nReferences:") + for ref in references: + number = ref.get("number", "-") + source = ref.get("source", "Unknown source") + ref_content = ref.get("content", "") + print(f"\n[{number}] {source}\n{ref_content}") + else: + print("\nNo references returned.") + - lang: Python - Fanar-Sadiq-2 + label: Python - Fanar-Sadiq-2 + source: > + # Fanar-Sadiq-2 requires additional authorization and is not allowed + by default. + + + import requests + + + def model_api(messages, madhab=None): + headers = { + "Authorization": "Bearer YOUR_API_KEY_HERE", + "Content-Type": "application/json", + } + + payload = { + "model": "Fanar-Sadiq-2", + "messages": messages, + } + if madhab: + payload["madhab"] = madhab + + response = requests.post( + "https://api.fanar.qa/v1/chat/completions", + json=payload, + headers=headers, + ) + return response.json() + + messages = [ + {"role": "user", "content": "What are the conditions for Zakat on gold according to the Hanafi school?"} + ] + + + response = model_api(messages=messages, madhab=["hanafi"]) + + + content = response["choices"][0]["message"]["content"] + + print("Assistant Response:\n") + + print(content) + + + # Print reference sources if present + + references = response["choices"][0]["message"].get("references", []) + + if references: + print("\nReferences:") + for ref in references: + number = ref.get("number", "-") + source = ref.get("source", "Unknown source") + ref_content = ref.get("content", "") + print(f"\n[{number}] {source}\n{ref_content}") + - lang: Python - Thinking mode (Fanar-C-1-8.7B) + label: Python - Thinking mode (Fanar-C-1-8.7B) + source: > + # Thinking mode requires additional authorization and is not allowed + by default. + + + import requests + + + def model_api(messages, max_tokens=500): + headers = { + "Authorization": "Bearer YOUR_API_KEY_HERE", + "Content-Type": "application/json", + } + + payload = { + "model": "Fanar-C-1-8.7B", + "messages": messages, + "max_tokens": max_tokens, + } + + response = requests.post( + "https://api.fanar.qa/v1/chat/completions", + json=payload, + headers=headers + ) + return response.json() + + # Step 1: User message with "thinking_user" role + + messages = [ + {"role": "thinking_user", "content": user_input} + ] + + + # Step 2: Send request to model with extended max tokens + + response = model_api( + messages=messages, + max_tokens=2000, + ) + + + output = response["choices"][0]["message"]["content"] + + finish_reason = response["choices"][0]["finish_reason"] + + + # Step 3: Check if thinking mode continuation is needed + + has_think_tag = "" in output + + hit_length_limit = finish_reason == "length" + + + if has_think_tag or hit_length_limit: + # Extract thinking output + thinking_output = output.split("")[0] if has_think_tag else output + + # Modify the last "thinking_user" role message to "user" + for msg in reversed(messages): + if msg["role"] == "thinking_user": + msg["role"] = "user" + break + + # Add new "thinking" role message with extracted output + messages.append({"role": "thinking", "content": thinking_output}) + + # Re-run model with updated messages, shorter max tokens + final_response = model_api( + messages=messages, + max_tokens=1000, + ) + + final_output = final_response["choices"][0]["message"]["content"] + else: + final_output = output + + # final_output now contains the full response after handling + thinking mode + + print("Assistant Response:\n") + + print(final_output) + - lang: Python - Thinking mode (Fanar-C-2-27B) + label: Python - Thinking mode (Fanar-C-2-27B) + source: > + # Thinking mode requires additional authorization and is not allowed + by default. + + + import requests + + + def model_api(messages, max_tokens=4000): + headers = { + "Authorization": "Bearer YOUR_API_KEY_HERE", + "Content-Type": "application/json", + } + + payload = { + "model": "Fanar-C-2-27B", + "messages": messages, + "max_tokens": max_tokens, + "enable_thinking": True + } + + response = requests.post( + "https://api.fanar.qa/v1/chat/completions", + json=payload, + headers=headers + ) + return response.json() + + messages = [ + {"role": "user", "content": user_input} + ] + + + response = model_api( + messages=messages + ) + + + print("Assistant Response:\n") + + print(response) + - lang: Python - Image understanding + label: Python - Image understanding + source: > + # Image understanding requires additional authorization and is not + allowed by default. + + + import requests + + import base64 + + + def model_api(messages): + headers = { + "Authorization": "Bearer YOUR_API_KEY_HERE", + "Content-Type": "application/json", + } + + payload = { + "model": "Fanar-Oryx-IVU-2", + "messages": messages, + "max_tokens": 750, + } + + response = requests.post("https://api.fanar.qa/v1/chat/completions", json=payload, headers=headers) + return response.json() + + with open("path/to/image.jpg", "rb") as image_file: + raw_b64 = base64.b64encode(image_file.read()).decode("utf-8") + + image_b64_url = f"data:image/jpeg;base64,{raw_b64}" + + + prompt = "Tell me about this image." + + + messages = [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt + }, + { + "type": "image_url", + "image_url": { + "url": image_b64_url + } + } + ] + } + ] + + + response = model_api(messages=messages) + + + content = response["choices"][0]["message"]["content"] + + print("Assistant Response:\n") + + print(content) + /v1/audio/speech: + post: + tags: + - Audio + summary: Create Speech + description: This endpoint is compatible with the OpenAI library.
Generates + audio from the input text. + operationId: create_speech_v1_audio_speech_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TextToSpeechRequest" + required: true + responses: + "200": + description: The audio file content or error details. + headers: + X-Id: + description: A unique identifier for the text-to-speech. + schema: + type: string + format: uuid + X-Revised-Input: + description: The processed input text after Quran validation and tagging. This + header is only present when using the Fanar-Sadiq-TTS model. The + validator identifies Quranic verses in the input and wraps them + with XML-style tags (e.g., ``, ``) for + proper handling during text-to-speech processing. It may also + apply corrections such as diacritical marks normalization or + verse formatting. If no Quranic content was detected or no + modifications were needed, this header will be absent. + schema: + type: string + content: + application/json: + schema: {} + audio/mpeg: + schema: + type: string + format: binary + audio/wav: + schema: + type: string + format: binary + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/audio/speech" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + --output greeting.mp3 \ + -d '{ + "model": "Fanar-Aura-TTS-2", + "input": "Hello! I hope you are having a wonderful day.", + "voice": "Amelia", + "response_format": "mp3" + }' + - lang: Python + label: Python - OpenAI + source: > + # Text-to-Speech requires additional authorization and is not + allowed by default. + + + from openai import OpenAI + + + client = OpenAI( + base_url="https://api.fanar.qa/v1", + api_key="YOUR_API_KEY" + ) + + + response = client.audio.speech.create( + model="Fanar-Aura-TTS-2", + input="Hello! I hope you are having a wonderful day.", + voice="Amelia", + response_format="mp3", + ) + + + with open("greeting.mp3", "wb") as f: + f.write(response.read()) + - lang: Python - requests + label: Python - requests + source: > + # Text-to-Speech requires additional authorization and is not + allowed by default. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/speech" + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json" + } + + data = { + "model": "Fanar-Aura-TTS-2", + "input": "Hello! I hope you are having a wonderful day.", + "voice": "Amelia", + "response_format": "mp3" + } + + + response = requests.post(url, headers=headers, json=data) + + + with open("greeting.mp3", "wb") as f: + f.write(response.content) + - lang: Python - requests for Quranic text + label: Python - requests for Quranic text + source: > + # Text-to-Speech requires additional authorization and is not + allowed by default. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/speech" + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json" + } + + data = { + "model": "Fanar-Sadiq-TTS-1", + "input": "Quranic text goes here", + "voice": "Amelia", + "quran_reciter": "abdul-basit", + "response_format": "mp3" + } + + + response = requests.post(url, headers=headers, json=data) + + + revised_input = response.headers.get('X-Revised-Input') + + if revised_input: + from urllib.parse import unquote + print("Revised Input:", unquote(revised_input)) + + with open("quranic_speech.mp3", "wb") as f: + f.write(response.content) + - lang: cURL - streaming + label: cURL - streaming + source: > + # Pass "stream": true to receive audio bytes progressively as they + are + + # synthesized. The response Content-Type is identical to the + non-streaming + + # case (audio/wav or audio/mpeg) but uses chunked transfer encoding, + so + + # `--output greeting.mp3` keeps working. The first byte arrives much + + # sooner for longer inputs. + + + curl -X POST "https://api.fanar.qa/v1/audio/speech" \ + + -H "Content-Type: application/json" \ + + -H "Authorization: Bearer YOUR_API_KEY" \ + + --no-buffer \ + + --output greeting.mp3 \ + + -d '{ + "model": "Fanar-Aura-TTS-2", + "input": "Hello! I hope you are having a wonderful day.", + "voice": "Amelia", + "response_format": "mp3", + "stream": true + }' + - lang: Python - OpenAI streaming + label: Python - OpenAI streaming + source: > + # Text-to-Speech requires additional authorization and is not + allowed by default. + + # Use the OpenAI SDK's with_streaming_response helper to play audio + as it + + # arrives. `stream: true` is passed via `extra_body` since it's a + + # Fanar-specific extension to the OpenAI-compatible schema. + + + from openai import OpenAI + + + client = OpenAI( + base_url="https://api.fanar.qa/v1", + api_key="YOUR_API_KEY" + ) + + + with client.audio.speech.with_streaming_response.create( + model="Fanar-Aura-TTS-2", + input="Hello! I hope you are having a wonderful day.", + voice="Amelia", + response_format="wav", + extra_body={"stream": True}, + ) as response: + response.stream_to_file("greeting.wav") + - lang: Python - requests streaming + label: Python - requests streaming + source: >- + # Text-to-Speech requires additional authorization and is not + allowed by default. + + # Pass stream=True to requests so it doesn't pre-buffer the body, + then + + # iterate over chunks. The first chunk arrives within ~1 s even for + long + + # inputs that would otherwise wait many seconds for the full + synthesis. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/speech" + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json" + } + + data = { + "model": "Fanar-Aura-TTS-2", + "input": "Hello! I hope you are having a wonderful day.", + "voice": "Amelia", + "response_format": "wav", + "stream": True + } + + + with requests.post(url, headers=headers, json=data, stream=True) as + r: + r.raise_for_status() + with open("greeting.wav", "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + /v1/audio/transcriptions: + post: + tags: + - Audio + summary: Create Transcription + description: This endpoint is compatible with the OpenAI + library.
Transcribes audio into the input language. + operationId: create_transcription_v1_audio_transcriptions_post + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/Body_create_transcription_v1_audio_transcriptions_p\ + ost" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: + anyOf: + - $ref: "#/components/schemas/SpeechToTextResponseWithText" + - $ref: "#/components/schemas/SpeechToTextResponseWithSRT" + - $ref: "#/components/schemas/SpeechToTextResponseWithJson-Output" + title: Response Create Transcription V1 Audio Transcriptions Post + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL Example + source: | + curl -X POST "https://api.fanar.qa/v1/audio/transcriptions" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@sample.wav" \ + -F "model=Fanar-Aura-STT-1" + - lang: Python + label: Python - OpenAI + source: > + # Audio Transcriptions requires additional authorization and is not + allowed by default. + + + from openai import OpenAI + + + client = OpenAI( + base_url="https://api.fanar.qa/v1", + api_key="YOUR_API_KEY" + ) + + + with open("sample.wav", "rb") as f: + response = client.audio.transcriptions.create( + file=f, + model="Fanar-Aura-STT-1" + ) + + print(response.text) + - lang: Python - requests + label: Python - requests + source: > + # Audio Transcriptions requires additional authorization and is not + allowed by default. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/transcriptions" + + headers = { + "Authorization": "Bearer YOUR_API_KEY" + } + + files = { + "file": open("sample.wav", "rb") + } + + data = { + "model": "Fanar-Aura-STT-1" + } + + + response = requests.post(url, headers=headers, files=files, + data=data) + + + print(response.json().get("text")) + - lang: Python - requests with longform audio (JSON format) + label: Python - requests with longform audio (JSON format) + source: >- + # Audio Transcriptions requires additional authorization and is not + allowed by default. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/transcriptions" + + headers = { + "Authorization": "Bearer YOUR_API_KEY" + } + + files = { + "file": open("sample.wav", "rb") + } + + data = { + "model": "Fanar-Aura-STT-LF-1", + "format": "json" + } + + + response = requests.post(url, headers=headers, files=files, + data=data) + + + print(response.json().get("json")) + /v1/audio/voices: + get: + tags: + - Audio + summary: List Voices + description: 'Lists all available text-to-speech voices.
The list will + include all built-in (public) voices by default. If your API key is + authorized to create + personalized voices, they will be included in the list and labeled + as type: "personal".' + operationId: list_voices_v1_audio_voices_get + responses: + "200": + description: A list of available voices. + content: + application/json: + schema: + $ref: "#/components/schemas/VoiceResponse" + example: + voices: + - name: Amelia + name_ar: أميليا + gender: Female + accent: British + languages: + - en + type: public + emotion: false + - name: Hamad + name_ar: حمد + gender: Male + accent: Gulf + languages: + - ar + type: public + emotion: false + - name: MyVoice + languages: [] + type: personal + emotion: false + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X GET "https://api.fanar.qa/v1/audio/voices" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" + - lang: Python + label: Python - requests + source: >- + # Voice personalization requires additional authorization and is not + allowed by default. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/voices" + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json" + } + + response = requests.get(url, headers=headers) + + voices = response.json() + + print(voices) + post: + tags: + - Audio + summary: Create Voice + description: Creates a personalized voice that can be used to generate + speech.
The voice name must be unique among your own personalized + voices. Names that match a built-in (public) voice are allowed — public + voices and your personalized voices live in separate namespaces. When + you request a voice name that exists in both, your personalized voice + takes precedence at synthesis time in POST + /v1/audio/speech.
This endpoint requires additional + authorization and is not allowed by default. Please contact support@fanar.qa. + operationId: create_voice_v1_audio_voices_post + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/Body_create_voice_v1_audio_voices_post" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/audio/voices" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -F "audio=@custom-voice-sample.wav;type=audio/wav" \ + -F "name=CustomVoice1" \ + -F "transcript=This is a sample transcription." + - lang: Python + label: Python - requests + source: > + # Voice personalization requires additional authorization and is not + allowed by default. + + + import requests + + from pydub import AudioSegment + + from io import BytesIO + + + url = "https://api.fanar.qa/v1/audio/voices" + + headers = { + "Authorization": "Bearer YOUR_API_KEY" + } + + + sample_audio_path = "custom-voice-sample.wav" + + audio = AudioSegment.from_wav(sample_audio_path) + + if audio.frame_rate != 24000: + audio = audio.set_frame_rate(24000) + + audio_buffer = BytesIO() + + audio.export(audio_buffer, format='wav') + + audio_buffer.seek(0) # Reset buffer position to start + + + files = {'audio': (os.path.basename(sample_audio_path), + audio_buffer, 'audio/wav')} + + + data = { + "name": "CustomVoice1", + "transcript": "This is a sample transcription." + } + + + requests.post(url, headers=headers, files=files, data=data) + /v1/audio/voices/{name}: + delete: + tags: + - Audio + summary: Delete Voice + description: Deletes a personalized voice by name.
This endpoint requires + additional authorization and is not allowed by default. Please contact + support@fanar.qa. + operationId: delete_voice_v1_audio_voices__name__delete + security: + - Bearer: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + "400": + description: The content was filtered + content: + application/json: + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + schema: + $ref: "#/components/schemas/Error" + "401": + description: Invalid authentication + content: + application/json: + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + schema: + $ref: "#/components/schemas/Error" + "403": + description: Invalid authorization + content: + application/json: + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + schema: + $ref: "#/components/schemas/Error" + "404": + description: Not found + content: + application/json: + example: + error: + code: Not found + message: Not found + status: 404 + schema: + $ref: "#/components/schemas/Error" + "409": + description: Conflict + content: + application/json: + example: + error: + code: conflict + message: Conflict + status: 409 + schema: + $ref: "#/components/schemas/Error" + "410": + description: No longer supported + content: + application/json: + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + schema: + $ref: "#/components/schemas/Error" + "413": + description: Request entity too large + content: + application/json: + example: + error: + code: too_large + message: Request entity too large + status: 413 + schema: + $ref: "#/components/schemas/Error" + "422": + description: Unprocessable + content: + application/json: + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + schema: + $ref: "#/components/schemas/Error" + "499": + description: Client closed request before completion + content: + application/json: + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error + content: + application/json: + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + schema: + $ref: "#/components/schemas/Error" + "503": + description: Service overloaded + content: + application/json: + example: + error: + code: overloaded + message: Service overloaded + status: 503 + schema: + $ref: "#/components/schemas/Error" + "504": + description: Request timed out + content: + application/json: + example: + error: + code: timeout + message: Request timed out + status: 504 + schema: + $ref: "#/components/schemas/Error" + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X DELETE "https://api.fanar.qa/v1/audio/voices/{name}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" + - lang: Python + label: Python - requests + source: > + # Voice personalization requires additional authorization and is not + allowed by default. + + + import requests + + + url = "https://api.fanar.qa/v1/audio/voices/{name}" + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json" + } + + requests.delete(url, headers=headers) + /v1/images/generations: + post: + tags: + - Images + summary: Create Image + description: This endpoint is compatible with the OpenAI library.
Creates an + image given a prompt. + operationId: create_image_v1_images_generations_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ImageGenerationRequest" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/ImageGenerationResponse" + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: > + curl -X POST "https://api.fanar.qa/v1/images/generations" \ + + -H "Content-Type: application/json" \ + + -H "Authorization: Bearer YOUR_API_KEY" \ + + -d '{ + "model": "Fanar-Oryx-IG-2", + "prompt": "A serene sunset over a mountain lake with reflections of colorful clouds and pine trees" + }' + - lang: Python + label: Python - OpenAI + source: > + # Image Generation requires additional authorization and is not + allowed by default. + + + import base64 + + from openai import OpenAI + + + client = OpenAI( + base_url="https://api.fanar.qa/v1", + api_key="YOUR_API_KEY", + ) + + + response = client.images.generate( + model="Fanar-Oryx-IG-2", + prompt="A serene sunset over a mountain lake with reflections of colorful clouds and pine trees" + ) + + + image_b64 = response.data[0].b64_json + + + image_bytes = base64.b64decode(image_b64) + + + with open("generated_image.png", "wb") as f: + f.write(image_bytes) + - lang: Python - requests + label: Python - requests + source: >- + # Image Generation requires additional authorization and is not + allowed by default. + + + import requests + + import base64 + + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + } + + + json_data = { + "model": "Fanar-Oryx-IG-2", + "prompt": "A serene sunset over a mountain lake with reflections of colorful clouds and pine trees" + } + + + response = requests.post( + "https://api.fanar.qa/v1/images/generations", + headers=headers, + json=json_data + ) + + + data = response.json() + + + image_b64 = data["data"][0]["b64_json"] + + + image_bytes = base64.b64decode(image_b64) + + + with open("generated_image.png", "wb") as f: + f.write(image_bytes) + /v1/translations: + post: + tags: + - Translations + summary: Translate + description: Translate the given text into the specified language. + operationId: translate_v1_translations_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TranslationRequest" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/TranslationResponse" + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/translations" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{ + "model": "Fanar-Shaheen-MT-1", + "text": "Your text here", + "langpair": "ar-en", + "preprocessing": "default" + }' + - lang: Python + label: Python - requests + source: >- + # Translation requires additional authorization and is not allowed + by default. + + + import requests + + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + } + + + json_data = { + "model": "Fanar-Shaheen-MT-1", + "text": "مرحبا بك في عالم الذكاء الاصطناعي!", + "langpair": "ar-en", + "preprocessing": "default", + } + + + response = requests.post("https://api.fanar.qa/v1/translations", + headers=headers, json=json_data) + + + print(response.json()) + /v1/poems/generations: + post: + tags: + - Poems + summary: Create Poem + description: This endpoint is compatible with the OpenAI library.
Creates a + poem given a prompt. + operationId: create_poem_v1_poems_generations_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PoemGenerationRequest" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/PoemGenerationResponse" + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/poems/generations" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{ + "model": "Fanar-Diwan", + "prompt": "Your text here" + }' + - lang: Python + label: Python - requests + source: >- + # Poem generation requires additional authorization and is not + allowed by default. + + + import requests + + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + } + + + json_data = { + "model": "Fanar-Diwan", + "prompt": "Your text here", + } + + + response = + requests.post("https://api.fanar.qa/v1/poems/generations", + headers=headers, json=json_data) + + + print(response.json()) + /v1/moderations: + post: + tags: + - Moderations + summary: Identify Safety + description: FanarGuard gives each prompt–response pair safety and + cultural-awareness scores, allowing moderation thresholds to be tailored + to the deployment.
For our definition of cultural awareness, refer + to [https://arxiv.org/abs/2511.18852](https://arxiv.org/abs/2511.18852). + operationId: identify_safety_v1_moderations_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SafetyFilterRequest" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/SafetyFilterResponse" + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/moderations" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{ + "model": "Fanar-Guard-2", + "prompt": "Your prompt here", + "response": "Response from the model here" + }' + - lang: Python + label: Python - requests + source: >- + import requests + + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + } + + + json_data = { + "model": "Fanar-Guard-2", + "prompt": "Your prompt here", + "response": "Response from the model here", + } + + + response = requests.post("https://api.fanar.qa/v1/moderations", + headers=headers, json=json_data) + + + print(response.json()) # Print the safety and cultural awareness + scores + /v1/tokens: + post: + tags: + - Tokens + summary: Get Tokens + operationId: get_tokens_v1_tokens_post + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TokenizationRequest" + required: true + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/TokenizationResponse" + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X POST "https://api.fanar.qa/v1/tokens" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{ + "content": "Your text content here", + "model": "Fanar-C-1-8.7B" + }' + - lang: Python + label: Python - requests + source: >- + import requests + + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + } + + + json_data = { + "content": "Your text content here", + "model": "Fanar-C-1-8.7B" + } + + + response = requests.post("https://api.fanar.qa/v1/tokens", + headers=headers, json=json_data) + + + print(response.json()) + /v1/models: + get: + tags: + - Models + summary: List Models + description: Lists the currently available models. + operationId: list_models_v1_models_get + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/ModelsResponse" + "400": + description: The content was filtered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: content_filter + message: The content was filtered + status: 400 + param: prompt + type: safety + "401": + description: Invalid authentication + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authentication + message: Invalid authentication + status: 401 + "403": + description: Invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: invalid_authorization + message: Invalid authorization + status: 403 + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: Not found + message: Not found + status: 404 + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: conflict + message: Conflict + status: 409 + "410": + description: No longer supported + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: no_longer_supported + message: No longer supported + status: 410 + "413": + description: Request entity too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: too_large + message: Request entity too large + status: 413 + "422": + description: Unprocessable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: unprocessable + message: Unprocessable + status: 422 + "429": + description: Rate limit reached or Exceeded quota + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: exceeded_quota + message: Exceeded quota + status: 429 + "499": + description: Client closed request before completion + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: client_closed_request + message: Client closed request before completion + status: 499 + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: internal_server_error + message: Internal server error + status: 500 + "503": + description: Service overloaded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: overloaded + message: Service overloaded + status: 503 + "504": + description: Request timed out + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: timeout + message: Request timed out + status: 504 + security: + - Bearer: [] + x-codeSamples: + - lang: Curl + label: cURL + source: | + curl -X GET "https://api.fanar.qa/v1/models" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" + - lang: Python + label: Python - requests + source: >- + import requests + + + headers = { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + } + + + response = requests.get("https://api.fanar.qa/v1/models", + headers=headers) + + + print(response.json()) +components: + schemas: + AvailableModel: + properties: + id: + type: string + title: Id + description: A unique identifier for the model. + object: + $ref: "#/components/schemas/ModelObject" + description: The type of object. + created: + type: integer + title: Created + description: The creation timestamp of the model. + owned_by: + type: string + title: Owned By + description: The owner of the model. + type: object + required: + - id + - object + - created + - owned_by + title: AvailableModel + Body_create_transcription_v1_audio_transcriptions_post: + properties: + file: + type: string + format: binary + title: File + description: The audio blob to transcribe. + model: + $ref: "#/components/schemas/STTModels" + description: >- + The model to use for the speech-to-text. + + - `Fanar-Aura-STT-1`: For short audio clips (up to 20–30 seconds). + + - `Fanar-Aura-STT-LF-1`: For long-form transcription of longer audio + files. + format: + $ref: "#/components/schemas/STTFormat" + description: >- + The format of the transcribed text. `Fanar-Aura-STT-1` only supports + `text` format. + + - `text`: Plain text format. + + - `srt`: SubRip Subtitle format. + + - `json`: JSON format with detailed transcription data. + default: text + type: object + required: + - file + - model + title: Body_create_transcription_v1_audio_transcriptions_post + Body_create_voice_v1_audio_voices_post: + properties: + name: + type: string + title: Name + description: The name of the personalized voice to be created. + audio: + type: string + format: binary + title: Audio + description: The audio sample to create the personalized voice. Only WAV format + is accepted. + transcript: + type: string + title: Transcript + description: The transcript of the audio sample. + type: object + required: + - name + - audio + - transcript + title: Body_create_voice_v1_audio_voices_post + BookNamesEnum: + type: string + enum: + - أصل الزراري شرح صحيح البخاري - مخطوط + - جمهرة تراجم الفقهاء المالكية + - مختصر تحفة المحتاج بشرح المنهاج + - شرح رياض الصالحين - حطيبة + - "تفسير العثيمين: الزمر" + - الموسوعة في صحيح السيرة النبوية - العهد المكي + - تيسير التفسير للقطان + - "تفسير العثيمين: السجدة" + - شرح زاد المستقنع - الشنقيطي - التفريغ + - تقويم طرق تعليم القرآن الكريم في مراحل التعليم العام والتعليم الجامعي + - شرح الورقات في أصول الفقه - الددو + - السراج الوهاج + - معلم التجويد + - فتح القدير للكمال بن الهمام - ط الحلبي + - دلائل الإعجاز بين أبي سعيد السيرافي والجرجاني + - معلمة الفقه المالكي + - الفرائض + - غزوات النبي + - فقه السيرة النبوية لمنير الغضبان + - شرح صحيح مسلم - حسن أبو الأشبال + - رسالة ابن القيم إلى أحد إخوانه - ط الشرق الأوسط + - مجموع فتاوى ورسائل العثيمين + - حاشية البجيرمي على الخطيب = تحفة الحبيب على شرح الخطيب + - جواهر البلاغة في المعاني والبيان والبديع + - أعلام السيرة النبوية في القرن الثاني للهجرة + - شرح سنن النسائي - الراجحي + - فتاوى واستشارات الإسلام اليوم + - مذكرة أصول الفقه - الجامعة الإسلامية + - الوفيات والأحداث + - صحيح كنوز السنة النبوية + - التفسير الميسر + - فتح السلام شرح عمدة الأحكام من فتح الباري + - المنتخب في ذكر نسب قبائل العرب + - المنهاج الواضح للبلاغة + - البيان والتعريف في أسباب ورود الحديث الشريف + - متون طالب العلم - المستوى الخامس - 3 + - تفسير أسماء الله الحسنى للسعدي + - شرح رياض الصالحين لابن عثيمين + - يسألونك عن رمضان + - تكملة المعاجم العربية + - التفسير الوسيط - الزحيلي + - التفسير القيم = تفسير القرآن الكريم لابن القيم + - قاموس الإملاء + - علوم البلاغة + - السيرة النبوية لأبي الحسن الندوي + - مدارج السالكين - ط عطاءات العلم + - نونية ابن القيم الكافية الشافية - ط عطاءات العلم + - المختصر في تفسير القرآن الكريم + - نيل الأوطار شرح منتقى الأخبار - ط الحديث + - التنوير شرح الجامع الصغير + - قواعد التجويد على رواية حفص عن عاصم بن أبي النجود + - شرح صحيح البخاري - عبد الكريم الخضير + - منهج الإمام أحمد في إعلال الأحاديث + - السيرة النبوية كما جاءت في الأحاديث الصحيحة + - تقريب فتاوى ابن تيمية + - ترجمة القرآن الكريم + - "تفسير العثيمين: الحجرات - الحديد" + - تحرير تقريب التهذيب + - المعتصر من شرح مختصر الأصول من علم الأصول + - علوم البلاغة «البديع والبيان والمعاني» + - الفقه المنهجي على مذهب الإمام الشافعي + - الإعراب المفصل لكتاب الله المرتل + - المختصر في المنطق + - مفهوم التفسير والتأويل والاستنباط والتدبر والمفسر + - الجرح والتعديل - اللاحم + - فتاوى مهمة تتعلق بالحج والعمرة + - الرسائل الحربية في عصر الدولة الأيوبية + - حاشية ابن عابدين = رد المحتار ط الحلبي + - عشائر العراق + - مدخل إلى التفسير وعلوم القرآن + - الإمام البخاري وكتابه الجامع الصحيح + - فائدة جليلة في قواعد الأسماء الحسنى + - التفسير الواضح + - المعجم الوسيط + - التسهيل في فقه الإمام أحمد - وزارة الأوقاف الكويتية + - أيسر التفاسير للجزائري + - أصول النحو 2 - جامعة المدينة + - بغية المقتصد شرح بداية المجتهد + - موجز التاريخ الإسلامي من عهد آدم إلى عصرنا الحاضر + - السيرة النبوية الصحيحة محاولة لتطبيق قواعد المحدثين في نقد روايات + السيرة النبوية + - نضرة النعيم في مكارم أخلاق الرسول الكريم + - قواعد الإملاء + - الرحيق المختوم + - البر والصلة لابن الجوزي + - مقاصد الشريعة الإسلامية + - دراسات في تاريخ العرب القديم + - إتحاف الأريب بشرح الغاية والتقريب + - سبل السلام شرح بلوغ المرام - ط الحديث + - اللؤلؤ المكنون في سيرة النبي المأمون + - نظرية المقاصد عند الإمام الشاطبي + - سير أعلام النبلاء - ط الرسالة + - القاموس المحيط + - البدع والمخالفات في الحج + - أساليب بلاغية + - غاية المرام في تخريج أحاديث الحلال والحرام + - الروح - ابن القيم - ط عطاءات العلم + - هداية الحيارى في أجوبة اليهود والنصارى - ط عطاءات العلم + - موسوعة المفاهيم الإسلامية العامة + - شرح الأربعين النووية - العباد + - "تفسير العثيمين: النساء" + - أصول الإيمان لمحمد بن عبد الوهاب - ت الجوابرة + - عون المعبود وحاشية ابن القيم + - تيسير اللطيف المنان في خلاصة تفسير القرآن - ط الأوقاف السعودية + - شرح عمدة الأحكام - عبد الكريم الخضير + - الوجيز في إيضاح قواعد الفقة الكلية + - حاشية السندي على سنن ابن ماجه + - "تفسير العثيمين: النمل" + - السيرة النبوية والدعوة في العهد المكي + - بغية الإيضاح لتلخيص المفتاح في علوم البلاغة + - قصة الحضارة + - فيض الباري على صحيح البخاري + - مناظرة بين الإسلام والنصرانية + - النظم البلاغي بين النظرية والتطبيق + - بحوث في تاريخ السنة المشرفة + - الوجيز في حكم تجويد الكتاب العزيز + - التخريج عند الفقهاء والأصوليين + - "تفسير العثيمين: الشعراء" + - الأسلوب + - المسند الجامع + - حاشية الصاوي على الشرح الصغير = بلغة السالك لأقرب المسالك + - تلخيص فقة الفرائض + - فقه السيرة للغزالي + - فقه المعاملات + - مختصر في قواعد التفسير + - سلسلة الآداب - المنجد + - الدرر البهية من الفتاوى الكويتية + - شرح الأربعين النووية للعثيمين + - التحرير والتنوير + - فتح القدير للشوكاني + - العقود الدرية في تنقيح الفتاوى الحامدية + - سلسلة الأحاديث الصحيحة وشيء من فقهها وفوائدها + - تفسير القرطبي = الجامع لأحكام القرآن + - فقه العبادات على المذهب الشافعي + - التحفة الندية شرح العقيدة الواسطية - عبد الرحمن العقل + - سلسلة الفوائد الحديثية والفقهية + - شرح المحرر في الحديث - عبد الكريم الخضير + - معجم تصحيح لغة الإعلام العربي + - آداب البحث والمناظرة + - شرح سنن الترمذي - عبد الكريم الخضير + - المجتبى من مشكل إعراب القرآن + - المختصر المفيد في أحكام التجويد - بآخر مصحف القراءات والتجويد + - مواهب الجليل من أدلة خليل + - التذهيب في أدلة متن الغاية والتقريب + - السيرة النبوية - راغب السرجاني + - معالم مكة التأريخية والأثرية + - "تفسير العثيمين: الأنعام" + - حياة محمد صلى الله عليه وآله وسلم + - متون طالب العلم - الإضافية - 2 + - التجريد لبغية المريد في القراءات السبع - ت ضاري + - الأحاديث الواردة في فضائل الصحابة + - لطائف قرآنية + - المتشابه + - تفسير البيضاوي = أنوار التنزيل وأسرار التأويل + - خصائص التراكيب دارسة تحليلية لمسائل علم المعاني + - "تفسير العثيمين: فصلت" + - فقه النوازل في العبادات + - الوابل الصيب - ط دار الحديث + - السيرة النبوية بين الآثار المروية والآيات القرآنية + - فتاوى دار الإفتاء المصرية + - زوائد الأحاديث الواردة في فضائل الصحابة + - شرح المدائح النبوية + - جامع تفاسير الأحلام = تنبيه الأفهام بتأويل الأحلام + - الطبقات للنسائي + - أصول الإيمان لابن باز + - صحيح السيرة النبوية للألباني + - "تفسير العثيمين: لقمان" + - شرح جامع الترمذي - الراجحي + - مدونة أحكام الوقف الفقهية + - معجم المعالم الجغرافية في السيرة النبوية + - الأديان والمذاهب - جامعة المدينة + - الفتح المبين بشرح الأربعين + - تحفة المودود بأحكام المولود - ط عطاءات العلم + - لا تحزن + - موسوعة الفرق المنتسبة للإسلام + - مائة من عظماء أمة الإسلام غيروا مجرى التاريخ + - منهج الإمام الطاهر بن عاشور في التفسير + - حاشية السيوطي على سنن النسائي + - السيرة النبوية منهجية دراستها واستعراض أحداثها + - "تفسير العثيمين: القصص" + - "تفسير العثيمين: النور" + - تذكرة الأريب في تفسير الغريب + - أثر العقيدة الإسلامية في تضامن ووحدة الأمة الإسلامية + - السياسة الشرعية - جامعة المدينة + - فتاوى إسلامية + - فقه السيرة النبوية مع موجز لتاريخ الخلافة الراشدة + - معجم الشعراء العرب + - القواعد الفقهية وتطبيقاتها في المذاهب الأربعة + - منهاج أهل السنة والجماعة في العقيدة والعمل + - الجنايات في الفقه الإسلامي دراسة مقارنة بين الفقه الإسلامي والقانون + - تسهيل الفرائض + - "تفسير العثيمين: الفاتحة والبقرة" + - شرح كتاب الإيمان - يوسف الغفيص + - صيد الخاطر + - المنيحة بسلسلة الأحاديث الصحيحة + - شرح صحيح البخاري - أسامة سليمان + - تلخيص الأصول + - الشامل في زكاة الأسهم واستثمار أموال الزكاة + - حاشية السندي على سنن الترمذي + - موسوعة أحكام الطهارة - الدبيان - ط 3 + - الموسوعة القرآنية + - المدخل في تاريخ السنة + - تطور كتابة المصحف الشريف وطباعته + - الأشباه والنظائر - ابن نجيم + - معجم قبائل المملكة العربية السعودية + - الدر المنثور في التفسير بالمأثور + - طبائع الاستبداد ومصارع الاستعباد + - الفوائد لابن القيم - ط عطاءات العلم + - موسوعة الأخلاق الإسلامية + - المدخل إلي جامع الترمذي + - العلاج والرقى + - تاريخ القرآن الكريم + - فتاوى الشبكة الإسلامية + - دولة الإسلام في الأندلس + - فتح الباري بشرح البخاري - ط السلفية + - شرح سنن أبي داود للعباد + - من شرح بلوغ المرام للطريفي + - التفسير الموضوعي 2 - جامعة المدينة + - المفصل فى تاريخ العرب قبل الإسلام + - الإعلام بأحكام المال الحرام + - إعانة الطالب في بداية علم الفرائض + - أسواق العرب في الجاهلية والإسلام + - منة المنعم في شرح صحيح مسلم + - شرح الأجرومية للأسمري + - يسألونك عن الزكاة + - شرح صحيح ابن خزيمة - الراجحي + - الآداب الإسلامية + - الأندلس من الفتح إلى السقوط + - تهذيب التهذيب - ط دبي + - شرح بلوغ المرام - اللهيميد + - العذب النمير من مجالس الشنقيطي في التفسير + - كيف تربي ولدك + - القطارة النحوية على المقدمة الآجرومية + - درر الحكام في شرح مجلة الأحكام + - مجموعة الوثائق السياسية للعهد النبوي والخلافة الراشدة + - الأحكام الشرعية المتعلقة بالوباء والطاعون مع دراسة فقهية للأحكام + المتعلقة بفيروس كورونا + - مقالات موقع الدرر السنية + - التفسير الوسيط - مجمع البحوث + - تاريخ العرب وحضارتهم في الأندلس + - متون طالب العلم - المستوى التمهيدي + - الشفعة بين الجمع العثماني والأحرف السبعة + - الشورى في الشريعة الإسلامية + - تحفة الأريب بما في القرآن من الغريب + - التعريف بالقرآن الكريم + - موسوعة الملل والأديان + - شرح الآجرومية - حسن حفظي + - الميسر في القراءات الأربع عشرة + - آيات متشابهات الألفاظ في القرآن الكريم وكيف التمييز بينها + - موجز دائرة المعارف الإسلامية + - التعريف بالإسلام + - معجم الصواب اللغوي + - مختصر تفسير البغوي المسمى بمعالم التنزيل + - "تفسير العثيمين: من جزء قد سمع وتبارك - ط مكتبة الطبري" + - شرح تفسير ابن كثير - الراجحي + - الداء والدواء = الجواب الكافي - ط دار المعرفة + - الفتاوى الاقتصادية + - القواعد الأصولية والفقهية المتعلقة بالمسلم غير المجتهد + - فقه الأسرة + - ورد اليوم والليلة + - معجم الدخيل في اللغة العربية الحديثة ولهجاتها + - الدرة السنية منظومة في علم الفرائض + - المال والحكم في الإسلام + - صحيح الكتب التسعة وزوائده + - "تفسير العثيمين: الأحزاب" + - فتح الكريم المنان في آداب حملة القرآن + - موسوعة الإعجاز العلمي في القرآن والسنة + - تيسير أحكام التجويد - المستوى الأول + - زاد المسير في علم التفسير + - سبيل المهتدين إلى شرح الأربعين النووية + - صحيح السيرة النبوية للعلي + - المعالم الأثيرة في السنة والسيرة + - "تفسير العثيمين: فاطر" + - مرويات السيرة لأكرم العمري + - عدة الصابرين وذخيرة الشاكرين - ط عطاءات العلم + - المعاجم المفهرسة لألفاظ القرآن الكريم + - شرح صحيح البخاري للحويني + - "تفسير العثيمين: الشورى" + - مناسبات الآيات والسور + - "تفسير العثيمين: العنكبوت" + - "تفسير العثيمين: الزخرف" + - دراسات في أصول اللغات العربية + - تاريخ العرب القديم + - معجزات القرآن العلمية + - شرح المعتمد في أصول الفقه + - الوجيز في علم التجويد + - مسائل مهمات تتعلق بفقه الصوم والتراويح والقراءة على الأموات + - الأزهر وأثره في النهضة الأدبية الحديثة + - تفسير أسماء الله الحسنى للزجاج + - تراجم منتخبة من «التهذيب» و «الميزان» - ضمن «آثار المعلمي» + - لسان العرب + - قواعد الفقه + - الهادي شرح طيبة النشر في القراءات العشر + - خاتم النبيين صلى الله عليه وآله وسلم + - البلاغة 2 - المعاني - جامعة المدينة + - صفوة التفاسير + - رسائل وفتاوى عبد العزيز آل الشيخ + - موسوعة القواعد الفقهية + - غريب الحديث - ابن الجوزي + - الإمام مسلم وصحيحه + - الكوكب الدري على جامع الترمذي + - معجم وتفسير لغوى لكلمات القرآن + - دراسات في فقه اللغة + - الفقه الميسر في ضوء الكتاب والسنة + - فقه العبادات على المذهب الحنفي + - تيسير العلام شرح عمدة الأحكام + - المنتخب في تفسير القرآن الكريم + - المدخل إلى دراسة المدارس والمذاهب الفقهية + - الموسوعة العقدية + - أثر اختلاف الأسانيد والمتون في اختلاف الفقهاء + - الروضة الندية شرح متن الجزرية + - الطبقات السنية في تراجم الحنفية + - اللغة وعلم اللغة + - المسائل الفقهية التي عليها الفتوى عند متأخري الحنفية - جمعا ودراسة- + - أصول الفقه الذي لا يسع الفقيه جهله + - التذييل على تهذيب التهذيب + - الرسول القائد + - العقد الثمين في شرح منظومة الشيخ ابن عثيمين + - متون طالب العلم - المستوى الثاني + - موسوعة المذاهب الفكرية المعاصرة + - الخلافة + - إعلام الموقعين عن رب العالمين - ط العلمية + - الخلاصة الفقهية على مذهب السادة المالكية + - الإسلام وأوضاعنا القانونية + - "تفسير العثيمين: سبأ" + - "تفسير العثيمين: المائدة" + - الأديان الوضعية - جامعة المدينة + - فتح العلام في دراسة أحاديث بلوغ المرام ط 4 + - القواعد والضوابط الفقهية المتضمنة للتيسير + - معجم الجرح والتعديل لرجال السنن الكبرى + - شرح مسند أبي حنيفة + - البحر المحيط الثجاج في شرح صحيح الإمام مسلم بن الحجاج + - شرح سنن النسائي المسمى شروق أنوار المنن الكبرى الإلهية بكشف أسرار + السنن الصغرى النسائية + - التوجيه والإرشاد النفسي + - فقه العبادات على المذهب المالكي + - الفتاوى الكبرى لابن تيمية + - متون طالب العلم - الإضافية - 1 + - جمع القرآن الكريم في عهد الخلفاء الراشدين - عبد القيوم السندي + - محاضرات في علوم الحديث + - علم الجرح والتعديل + - موسوعة تفسير الأحلام + - لمسات بيانية في نصوص من التنزيل - كتاب + - شرح المقدمة الحضرمية المسمى بشرى الكريم بشرح مسائل التعليم + - شرح القواعد الفقهية + - "تفسير العثيمين: يس" + - الرقية الشرعية + - مدرسة الحديث في مصر + - شرح الأربعين النووية - عبد الكريم الخضير + - شرح العقيدة الواسطية - الغنيمان + - التفسير القرآني للقرآن + - القاموس الفقهي + - المغني لابن قدامة - ت التركي + - تفسير ابن رجب الحنبلي + - صفة الصفوة + - متون طالب العلم - المستوى الخامس - 2 + - موسوعة مرآة الحرمين الشريفين وجزيرة العرب + - فتاوى اللجنة الدائمة - المجموعة الأولى + - جوامع الدعاء + - تاريخ نزول القرآن + - تاريخ شبه الجزيرة العربية في عصورها القديمة + - حياة الصحابة + - مدخل في علوم القراءات + - بينات الرسول صلى الله عليه وآله وسلم ومعجزاته + - موسوعة التفسير المأثور + - نحو معجم تاريخي للمصطلحات القرآنية المعرفة + - توفيق الرب المنعم بشرح صحيح الإمام مسلم + - مجموعة رسائل وفتاوى في مسائل مهمة تمس إليها حاجة العصر + - شرح مسند الدارمي + - "تفسير العثيمين: جزء عم" + - الإيمان حقيقته، خوارمه، نواقضه عند أهل السنة والجماعة + - اللغة العربية معناها ومبناها + - "تفسير العثيمين: غافر" + - السرايا والبعوث النبوية حول المدينة ومكة + - جمهرة الأجزاء الحديثية + - الإسلام وأوضاعنا السياسية + - القرآن وإعجازه العلمي + - متون طالب العلم - الإضافية - 4 + - الكواشف الجلية في حكم قراءة القرآن بالمقامات الموسيقية + - معالم أصول الفقه عند أهل السنة والجماعة + - صفحات في علوم القراءات + - المدخل إلى صحيح البخاري + - المجالس الفقهية + - زاد المعاد في هدي خير العباد - ط عطاءات العلم + - الموسوعة التاريخية + - شرح سنن أبي داود - الراجحي + - البديع عند الحريري + - تقريب التهذيب + - صحيح الأثر وجميل العبر من سيرة خير البشر (صلى الله عليه وسلم) + - فقه الدعوة الإسلامية في الغرب ووجوب تجديدها على الحكمة والوسطية + والاعتدال + - متون طالب العلم - الإضافية - 10 + - اللباب في قواعد اللغة وآلات الأدب النحو والصرف والبلاغة والعروض واللغة + والمثل + - شذرات الذهب دراسة في البلاغة القرآنية + - علم اللغة مقدمة للقارئ العربي + - شرح التدمرية - محمد بن خليفة التميمي + - المنهج الحركي للسيرة النبوية + - شرح عمدة الأحكام لابن جبرين + - السيرة النبوية - دروس وعبر + - شرح مقدمة في أصول التفسير لابن تيمية + - الجامع لكتب الضعفاء والمتروكين والكذابين + - شرح كتاب الفتن من صحيح البخاري - عبد الكريم الخضير + - تفسير ابن كثير - ط ابن الجوزي + - تفسير القرآن الكريم - المقدم + - البلاغة 1 - البيان والبديع - جامعة المدينة + - شرح مائة المعاني والبيان + - الخلاصة البهية في ترتيب أحداث السيرة النبوية + - الدرر الثرية من الفتاوى البازية + - فتح الودود في شرح سنن أبي داود + - علم البديع + - طريق الهجرتين وباب السعادتين - ط عطاءات العلم + - المنهل العذب المورود شرح سنن أبي داود + - الموسوعة القرآنية المتخصصة + - متون طالب العلم - الإضافية - 8 + - بحوث ومقالات في اللغة + - إغاثة اللهفان في مصايد الشيطان - ت الفقي + - منتقى الأذكار + - الفقه الميسر + - سلسلة القصص - المنجد + - الصحاح تاج اللغة وصحاح العربية + - إكمال تهذيب الكمال - ط العلمية + - البدهيات في القرآن الكريم + - القيم الإسلامية + - المسلمون في بلاد الغربة + - موسوعة سفير للتاريخ الإسلامي + - مهمات في أحكام المواريث + - الفتاوى اليومية من المسائل الفقهية + - "تفسير العثيمين: الروم" + - كيف تحفظ القرآن الكريم + - مقدمات في علم القراءات + - فتح المنعم شرح صحيح مسلم + - الإتقان في ضوابط تسجيل القرآن + - نزول القران الكريم وتاريخه وما يتعلق به + - شرح الموطأ - عبد الكريم الخضير + - موجز عن الفتوحات الإسلامية + - الاستقصا لأخبار دول المغرب الأقصى + - الرسالة الندية في القواعد الفقهية + - فقه الهندسة المالية الإسلامية + - التربية الإسلامية أصولها ومنهجها ومعلمها + - موسوعة القبائل العربية + - تفسير السعدي = تيسير الكريم الرحمن + - شرح سنن ابن ماجه للهرري = مرشد ذوي الحجا والحاجة إلى سنن ابن ماجه + - متون طالب العلم - الإضافية - 3 + - المدخل إلى دراسة المذاهب الفقهية + - التفسير الموضوعي للقرآن الكريم ونماذج منه + - معجم قبائل العرب القديمة والحديثة + - محمد صلى الله عليه وسلم + - فقه الأدعية والأذكار + - يسألونك عن المعاملات المالية المعاصرة + - شرح العقيدة الأصفهانية + - الطبقات الكبرى - ط الخانجي + - الفقه والشريعة + - شرح سنن ابن ماجة - الراجحي + - الجامع في أمثال القرآن + - سبل السلام من صحيح سيرة خير الأنام عليه الصلاة والسلام + - موسوعة صناعة الحلال + - تفسير غريب ما في الصحيحين البخاري ومسلم + - "تفسير العثيمين: الفرقان" + - شرح الترغيب والترهيب للمنذرى - حطيبة + - خطب مختارة + - روضة المحبين ونزهة المشتاقين - ط عطاءات العلم + - الحديث الموضوعي المنهج والتأصيل والتمثيل + - مدخل إلى علوم الشريعة + - الإسلام والحكم + - "تفسير العثيمين: ص" + - المعجم الجامع في تراجم المعاصرين + - البلاغة العربية + - متون طالب العلم - المستوى الأول + - الأخلاق في الإسلام + - الموسوعة الفقهية + - البحر المديد في تفسير القرآن المجيد + - علم البيان + - التشريع الجنائي الإسلامي مقارنا بالقانون الوضعي + - "تفسير العثيمين: آل عمران" + - الأساس في السنة وفقهها - السيرة النبوية + - التوضيح والبيان لشجرة الإيمان + - معرفة القراء الكبار على الطبقات والأعصار + - تفسير البغوي - طيبة + - فتح القوي المتين في شرح الأربعين وتتمة الخمسين للنووي وابن رجب رحمهما + الله + - علم الفرائض والمواريث في الشريعة الإسلامية والقانون السوري + - نزول القرآن الكريم والعناية به في عهد الرسول صلى الله عليه وسلم + - بذل المجهود في حل سنن أبي داود + - مناهج البحث في العلوم السياسية + - فتاوى الطب والمرضى + - متون طالب العلم - الإضافية - 5 + - تفسير الشعراوي + - برنامجك في رمضان + - شرح بلوغ المرام - عبد الكريم الخضير + - علم المعاني + - المنتخب من وصايا الآباء للأبناء + - مختصر سيرة الرسول صلى الله عليه وسلم لمحمد بن عبد الوهاب + - آل الجرباء في التاريخ والأدب + - البلاغة الصافية في المعاني والبيان والبديع + - شرح رسالة لطيفة جامعة في أصول الفقه المهمة + - "تفسير العثيمين: الصافات" + - التعريفات الفقهية + - متون طالب العلم - الإضافية - 6 + - شرح العقيدة السفارينية + - تاريخ الدولة العلية العثمانية + - الجامع الصحيح للسيرة النبوية + - إسعاف الأعيان في أنساب أهل عمان + - أسرار البيان في التعبير القرآني - كتاب + - النظام القضائي في الفقه الإسلامي + - علم اللغة + - الفتاوى العالمكيرية = الفتاوى الهندية + - روح البيان + - اصطلاح المذهب عند المالكية + - التفسير الوسيط لطنطاوي + - مجموعة الرسائل والمسائل النجدية (الجزء الرابع، القسم الثاني) + - فقه عمل اليوم والليلة + - أصول الإيمان في ضوء الكتاب والسنة + - الإيمان لابن تيمية + - شرح العقيدة الطحاوية - عبد العزيز الراجحي + - "تفسير العثيمين: الكهف" + - أصول النحو 1 - جامعة المدينة + - وجوب تطبيق الشريعة الإسلامية في كل عصر + - الموسوعة الفقهية الكويتية + - موسوعة الإجماع في الفقه الإسلامي - ط الفضيلة + - صحيح سنن أبي داود ط غراس + - إعراب القرآن الكريم - ط دار الصحابة + - التفسير النبوي + - شرح العقيدة الواسطية - العثيمين + - المغني في ضبط الأسماء لرواة الأنباء + - كتاب سيرة النبي صلى الله عليه وسلم + - نور اليقين في سيرة سيد المرسلين + - لمحات مهمة في الوصية + - مختصر زاد المعاد + - العقيدة الصحيحة وما يضادها ونواقض الإسلام + - المعاجم العربية مع اعتناء خاص بمعجم العين للخليل بن أحمد + - مقتطفات من السيرة + - معجم اللغة العربية المعاصرة + - مدخل إلى تفسير القرآن وعلومه + - فقه النكاح والفرائض + - الحافظ العراقي وكتابه تكملة شرح الترمذي - مجلة الهند + - شرح العقيدة الطحاوية - صالح آل الشيخ = إتحاف السائل بما في الطحاوية من + مسائل + - تفسير الألوسي = روح المعاني + - سلسلة الآثار الصحيحة أو الصحيح المسند من أقوال الصحابة والتابعين + - حاشية السندي على سنن النسائي + - معجم لغة الفقهاء + - متون طالب العلم - المستوى الخامس - 1 + - مختصر تفسير ابن كثير + - أوضح التفاسير + - شرح صحيح ابن حبان - الراجحي + - التحفة المكية في توضيح أهم القواعد الفقهية + - تاريخ الخلفاء الراشدين الفتوحات والإنجازات السياسية + - شرح متن أبي شجاع - محمد حسن عبد الغفار + - "تفسير العثيمين: من سورة محمد - ط مكتبة الطبري" + - تجريد القواعد والفوائد الأصولية + - شمائل الرسول صلى الله عليه وآله وسلم + - المنهج التأصيلي لدراسة التفسير التحليلي + - شرح مقدمة سنن ابن ماجه + - نظام الإثبات في الفقه الإسلامي + - الرسالة التبوكية زاد المهاجر إلى ربه - ت غازي + - شرح كتاب الإيمان الأوسط لابن تيمية - الراجحي + - موسوعة الأعلام - الأوقاف المصرية + - الكوكب الوهاج شرح صحيح مسلم بن الحجاج + - صحيح سنن النسائي + - شرح اختصار علوم الحديث - اللاحم + - مختار الصحاح + - أضواء البيان في إيضاح القرآن بالقرآن - ط عطاءات العلم + - مجلة الأحكام العدلية + - أسرار المحبين في رمضان + - السياسة الشرعية في الشئون الدستورية والخارجية والمالية + - نزول القرآن على سبعة أحرف + - دليل الحاج والمعتمر وزائر مسجد الرسول صلى الله عليه وسلم + - فتاوى عاجلة لمنسوبي الصحة + - المغالطات المنطقية + - فقه السنة + - من قضايا البلاغة والنقد عند عبد القادر الجرجاني + - مجموعة الرسائل والمسائل النجدية (الجزء الأول) + - متون طالب العلم - المستوى الثالث + - متون طالب العلم - المستوى الرابع + - تفسير القرآن الكريم وإعرابه وبيانه - الدرة + - فتاوى اللجنة الدائمة - المجموعة الثانية + - الموسوعة الميسرة في الأديان والمذاهب والأحزاب المعاصرة + - التحالف السياسي في الإسلام + title: BookNamesEnum + ChatCompletionAssistantMessageParam: + properties: + role: + type: string + const: assistant + title: Role + content: + anyOf: + - type: string + - items: + anyOf: + - $ref: "#/components/schemas/ChatCompletionContentPartTextParam" + - $ref: "#/components/schemas/ChatCompletionContentPartRefusalParam" + type: array + - type: "null" + title: Content + name: + type: string + title: Name + tool_calls: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionToolCall" + type: array + - type: "null" + title: Tool Calls + type: object + required: + - role + title: ChatCompletionAssistantMessageParam + ChatCompletionChoice-Input: + properties: + finish_reason: + type: string + enum: + - stop + - length + - tool_calls + - content_filter + - function_call + title: Finish Reason + index: + type: integer + title: Index + logprobs: + anyOf: + - $ref: "#/components/schemas/ChoiceLogprobs-Input" + - type: "null" + message: + $ref: "#/components/schemas/ChatCompletionChoiceMessage-Input" + additionalProperties: true + type: object + required: + - finish_reason + - index + - message + title: ChatCompletionChoice + ChatCompletionChoice-Output: + properties: + finish_reason: + type: string + enum: + - stop + - length + - tool_calls + - content_filter + - function_call + title: Finish Reason + index: + type: integer + title: Index + logprobs: + anyOf: + - $ref: "#/components/schemas/ChoiceLogprobs-Output" + - type: "null" + message: + $ref: "#/components/schemas/ChatCompletionChoiceMessage-Output" + additionalProperties: true + type: object + required: + - finish_reason + - index + - message + title: ChatCompletionChoice + ChatCompletionChoiceMessage-Input: + properties: + content: + anyOf: + - type: string + - items: + anyOf: + - $ref: "#/components/schemas/ChatCompletionContentText" + - $ref: "#/components/schemas/ChatCompletionContentImage" + - $ref: "#/components/schemas/ChatCompletionContentAudio" + type: array + - type: "null" + title: Content + role: + type: string + const: assistant + title: Role + references: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionChoiceMessageReference" + type: array + - type: "null" + title: references + description: The list of references used in the response + tool_calls: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionToolCall" + type: array + - type: "null" + title: tool_calls + description: The list of tool calls made by the assistant. + type: object + required: + - role + title: ChatCompletionChoiceMessage + ChatCompletionChoiceMessage-Output: + properties: + content: + anyOf: + - type: string + - items: + anyOf: + - $ref: "#/components/schemas/ChatCompletionContentText" + - $ref: "#/components/schemas/ChatCompletionContentImage" + - $ref: "#/components/schemas/ChatCompletionContentAudio" + type: array + - type: "null" + title: Content + role: + type: string + const: assistant + title: Role + references: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionChoiceMessageReference" + type: array + - type: "null" + title: references + description: The list of references used in the response + tool_calls: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionToolCall" + type: array + - type: "null" + title: tool_calls + description: The list of tool calls made by the assistant. + type: object + required: + - role + title: ChatCompletionChoiceMessage + ChatCompletionChoiceMessageReference: + properties: + index: + description: The location of the reference in the response + title: index + type: integer + number: + description: The reference number in the response + title: number + type: integer + source: + description: The source of the reference + title: source + type: string + content: + description: The content of the reference + title: content + type: string + required: + - index + - number + - source + - content + title: ChatCompletionChoiceMessageReference + type: object + ChatCompletionContentAudio: + properties: + type: + type: string + const: audio_url + title: Type + audio_url: + $ref: "#/components/schemas/URL" + type: object + required: + - type + - audio_url + title: ChatCompletionContentAudio + ChatCompletionContentImage: + properties: + type: + type: string + const: image_url + title: Type + image_url: + $ref: "#/components/schemas/URL" + type: object + required: + - type + - image_url + title: ChatCompletionContentImage + ChatCompletionContentPartImageParam: + properties: + image_url: + $ref: "#/components/schemas/ImageURL" + type: + type: string + const: image_url + title: Type + type: object + required: + - image_url + - type + title: ChatCompletionContentPartImageParam + ChatCompletionContentPartRefusalParam: + properties: + refusal: + type: string + title: Refusal + type: + type: string + const: refusal + title: Type + type: object + required: + - refusal + - type + title: ChatCompletionContentPartRefusalParam + ChatCompletionContentPartTextParam: + properties: + text: + type: string + title: Text + type: + type: string + const: text + title: Type + type: object + required: + - text + - type + title: ChatCompletionContentPartTextParam + ChatCompletionContentPartVideoParam: + properties: + video_url: + $ref: "#/components/schemas/VideoURL" + type: + type: string + const: video_url + title: Type + type: object + required: + - video_url + - type + title: ChatCompletionContentPartVideoParam + ChatCompletionContentText: + properties: + type: + type: string + const: text + title: Type + text: + type: string + title: Text + type: object + required: + - type + - text + title: ChatCompletionContentText + ChatCompletionLLM: + type: string + enum: + - Fanar + - Fanar-S-1-7B + - Fanar-C-1-8.7B + - Fanar-C-2-27B + - Fanar-Sadiq + - Fanar-Sadiq-2 + - Fanar-Oryx-IVU-2 + title: ChatCompletionLLM + ChatCompletionRequest: + properties: + messages: + items: + anyOf: + - $ref: "#/components/schemas/ChatCompletionSystemMessageParam" + - $ref: "#/components/schemas/ChatCompletionUserMessageParam" + - $ref: "#/components/schemas/ChatCompletionAssistantMessageParam" + - $ref: "#/components/schemas/ChatCompletionThinkingUserMessageParam" + - $ref: "#/components/schemas/ChatCompletionThinkingMessageParam" + type: array + title: Messages + model: + $ref: "#/components/schemas/ChatCompletionLLM" + description: The model to use for the completion. For the Fanar-Sadiq and + Fanar-Sadiq-2 models, the following LLM parameters are not used. + enable_thinking: + anyOf: + - type: boolean + - type: "null" + title: Enable Thinking + description: Whether to enable the thinking role in the conversation. This only + applies if the model supports it. Currently, only the Fanar-C-2-27B + model supports this parameter with additional authorization. + default: false + frequency_penalty: + anyOf: + - type: number + - type: "null" + title: Frequency Penalty + description: A penalty for how much new tokens should avoid repeating existing + ones. + default: 0 + logit_bias: + anyOf: + - additionalProperties: + type: number + type: object + - type: "null" + title: Logit Bias + description: Modify the likelihood of specified tokens appearing in the + completion. + logprobs: + anyOf: + - type: boolean + - type: "null" + title: Logprobs + description: Whether to return log probabilities of the output tokens or not. + top_logprobs: + anyOf: + - type: integer + - type: "null" + title: Top Logprobs + description: An integer between 0 and 20 specifying the number of most likely + tokens toreturn at each token position, each with an associated log + probability. + max_tokens: + anyOf: + - type: integer + - type: "null" + title: Max Tokens + description: The maximum number of tokens that can be generated in the chat + completion. + n: + anyOf: + - type: integer + - type: "null" + title: N + description: How many chat completion choices to generate for each input message. + presence_penalty: + anyOf: + - type: number + - type: "null" + title: Presence Penalty + description: Number between -2.0 and 2.0. Positive values penalize new tokens + based onwhether they appear in the text so far, increasing the + model's likelihood totalk about new topics. + stop: + anyOf: + - type: string + - items: + type: string + type: array + - type: "null" + title: Stop + description: Up to 4 sequences where the API will stop generating further tokens. + stream: + anyOf: + - type: boolean + - type: "null" + title: Stream + description: Whether to stream back partial progress as tokens are generated. + default: false + temperature: + anyOf: + - type: number + - type: "null" + title: Temperature + description: The sampling temperature, where 0.0 means deterministic output. + default: 0 + top_p: + anyOf: + - type: number + - type: "null" + title: Top P + description: Controls the cumulative probability of the top tokens to consider. + best_of: + anyOf: + - type: integer + - type: "null" + title: Best Of + description: Number of output sequences that are generated from the prompt. + top_k: + anyOf: + - type: integer + - type: "null" + title: Top K + description: Controls the number of top tokens to consider. + min_p: + anyOf: + - type: number + - type: "null" + title: Min P + description: Represents the minimum probability for a token to be considered, + relative to the probability of the most likely token. + repetition_penalty: + anyOf: + - type: number + - type: "null" + title: Repetition Penalty + description: A penalty for repeating the same tokens in the generated output. + default: 1.1 + length_penalty: + anyOf: + - type: number + - type: "null" + title: Length Penalty + description: Penalizes sequences based on their length. Used in beam search. + early_stopping: + anyOf: + - type: boolean + - type: "null" + title: Early Stopping + description: Controls the stopping condition for beam-based methods. + stop_token_ids: + anyOf: + - items: + type: integer + type: array + - type: "null" + title: Stop Token Ids + description: List of tokens that stop the generation when they are generated. + ignore_eos: + anyOf: + - type: boolean + - type: "null" + title: Ignore Eos + description: Whether to ignore the EOS token and continue generating tokens + after the EOS token is generated. + min_tokens: + anyOf: + - type: integer + - type: "null" + title: Min Tokens + description: Minimum number of tokens to generate per output sequence before EOS + or stop_token_ids can be generated. + skip_special_tokens: + anyOf: + - type: boolean + - type: "null" + title: Skip Special Tokens + description: Whether to skip special tokens in the output. + spaces_between_special_tokens: + anyOf: + - type: boolean + - type: "null" + title: Spaces Between Special Tokens + description: Whether to add spaces between special tokens in the output. + truncate_prompt_tokens: + anyOf: + - type: integer + - type: "null" + title: Truncate Prompt Tokens + description: If set to an integer k, will use only the last k tokens from the + prompt + prompt_logprobs: + anyOf: + - type: integer + - type: "null" + title: Prompt Logprobs + description: Number of log probabilities to return per prompt token. + book_names: + anyOf: + - items: + $ref: "#/components/schemas/BookNamesEnum" + type: array + - type: "null" + title: Book Names + description: List of book names to use for the Fanar-Sadiq model. + preferred_sources: + anyOf: + - items: + anyOf: + - $ref: "#/components/schemas/SourcesEnum" + - type: string + type: array + - type: "null" + title: Preferred Sources + description: List the preferred sources to use for the Fanar-Sadiq model, in + order of priority, with fallback sources used if needed. Accepts + predefined source names or values starting with 'digital_seerah'. + exclude_sources: + anyOf: + - items: + anyOf: + - $ref: "#/components/schemas/SourcesEnum" + - type: string + type: array + - type: "null" + title: Exclude Sources + description: List of sources to exclude from the Fanar-Sadiq model. Accepts + predefined source names or values starting with 'digital_seerah'. + filter_sources: + anyOf: + - items: + anyOf: + - $ref: "#/components/schemas/SourcesEnum" + - type: string + type: array + - type: "null" + title: Filter Sources + description: List of sources to filter from the Fanar-Sadiq model. Accepts + predefined source names or values starting with 'digital_seerah'. + madhab: + anyOf: + - items: + $ref: "#/components/schemas/MadhabEnum" + type: array + - type: "null" + title: Madhab + description: List of madhab (Islamic school of thought) to filter by for the + Fanar-Sadiq-2 model. + restrict_to_islamic: + anyOf: + - type: boolean + - type: "null" + title: Restrict To Islamic + description: When enabled for Fanar-Sadiq model, only Islamic content prompts + will be accepted. Non-Islamic content will be rejected. + default: false + persona: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + title: Persona + description: Custom persona that controls the assistant's voice and identity for + the Fanar-Sadiq model. Free-form text; only supported for + Fanar-Sadiq. + type: object + required: + - messages + - model + title: ChatCompletionRequest + example: + model: Fanar + messages: + - role: user + content: Hello + ChatCompletionRequestVoiceLangaugePair: + properties: + voice: + anyOf: + - type: string + - type: "null" + title: Voice + language: + type: string + title: Language + type: object + required: + - language + title: ChatCompletionRequestVoiceLangaugePair + ChatCompletionResponse: + properties: + id: + type: string + title: Id + description: A unique identifier for the chat completion + choices: + items: + $ref: "#/components/schemas/ChatCompletionChoice-Output" + type: array + title: Choices + description: A list of chat completion choices.Can be more than one if `n` is + greater than 1. + created: + type: integer + title: Created + description: The Unix timestamp (in seconds) of when the chat completion was + created. + model: + type: string + title: Model + description: The model used for the chat completion. + object: + type: string + const: chat.completion + title: Object + description: The object type + default: chat.completion + usage: + anyOf: + - $ref: "#/components/schemas/CompletionUsage" + - type: "null" + description: Usage statistics for the completion request. + metadata: + anyOf: + - additionalProperties: true + type: object + - type: "null" + title: Metadata + description: Additional metadata associated with the completion. + type: object + required: + - id + - choices + - created + - model + title: ChatCompletionResponse + ChatCompletionSystemMessageParam: + properties: + content: + anyOf: + - type: string + - items: + $ref: "#/components/schemas/ChatCompletionContentPartTextParam" + type: array + title: Content + role: + type: string + const: system + title: Role + name: + type: string + title: Name + type: object + required: + - content + - role + title: ChatCompletionSystemMessageParam + ChatCompletionThinkingMessageParam: + properties: + role: + type: string + const: thinking + title: Role + content: + type: string + title: Content + type: object + required: + - role + - content + title: ChatCompletionThinkingMessageParam + ChatCompletionThinkingUserMessageParam: + properties: + role: + type: string + const: thinking_user + title: Role + content: + type: string + title: Content + type: object + required: + - role + - content + title: ChatCompletionThinkingUserMessageParam + ChatCompletionTokenLogprob: + properties: + token: + type: string + title: Token + bytes: + anyOf: + - items: + type: integer + type: array + - type: "null" + title: Bytes + logprob: + type: number + title: Logprob + top_logprobs: + items: + $ref: "#/components/schemas/TopLogprob" + type: array + title: Top Logprobs + additionalProperties: true + type: object + required: + - token + - logprob + - top_logprobs + title: ChatCompletionTokenLogprob + ChatCompletionToolCall: + properties: + id: + type: string + title: id + description: The unique identifier for the tool call + name: + type: string + title: name + description: The name of the tool called + arguments: + additionalProperties: true + type: object + title: arguments + description: The arguments passed to the tool + result: + anyOf: + - {} + - type: "null" + title: result + description: The result returned by the tool + structured_content: + anyOf: + - {} + - type: "null" + title: structured_content + description: The structured content returned by the tool + is_error: + anyOf: + - type: boolean + - type: "null" + title: is_error + description: Indicates if there was an error during the tool call + type: object + required: + - id + - name + - arguments + title: ChatCompletionToolCall + ChatCompletionUserMessageParam: + properties: + content: + anyOf: + - type: string + - items: + anyOf: + - $ref: "#/components/schemas/ChatCompletionContentPartTextParam" + - $ref: "#/components/schemas/ChatCompletionContentPartImageParam" + - $ref: "#/components/schemas/ChatCompletionContentPartVideoParam" + type: array + title: Content + role: + type: string + const: user + title: Role + name: + type: string + title: Name + type: object + required: + - content + - role + title: ChatCompletionUserMessageParam + ChoiceLogprobs-Input: + properties: + content: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionTokenLogprob" + type: array + - type: "null" + title: Content + refusal: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionTokenLogprob" + type: array + - type: "null" + title: Refusal + additionalProperties: true + type: object + title: ChoiceLogprobs + ChoiceLogprobs-Output: + properties: + content: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionTokenLogprob" + type: array + - type: "null" + title: Content + refusal: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionTokenLogprob" + type: array + - type: "null" + title: Refusal + additionalProperties: true + type: object + title: ChoiceLogprobs + CompletionTokensDetails: + additionalProperties: true + properties: + accepted_prediction_tokens: + anyOf: + - type: integer + - type: "null" + default: null + title: Accepted Prediction Tokens + audio_tokens: + anyOf: + - type: integer + - type: "null" + default: null + title: Audio Tokens + reasoning_tokens: + anyOf: + - type: integer + - type: "null" + default: null + title: Reasoning Tokens + rejected_prediction_tokens: + anyOf: + - type: integer + - type: "null" + default: null + title: Rejected Prediction Tokens + title: CompletionTokensDetails + type: object + CompletionUsage: + additionalProperties: true + properties: + completion_tokens: + title: Completion Tokens + type: integer + prompt_tokens: + title: Prompt Tokens + type: integer + total_tokens: + title: Total Tokens + type: integer + completion_tokens_details: + anyOf: + - $ref: "#/components/schemas/CompletionTokensDetails" + - type: "null" + default: null + prompt_tokens_details: + anyOf: + - $ref: "#/components/schemas/PromptTokensDetails" + - type: "null" + default: null + required: + - completion_tokens + - prompt_tokens + - total_tokens + title: CompletionUsage + type: object + Error: + properties: + code: + $ref: "#/components/schemas/ErrorCode" + message: + type: string + title: Message + default: Internal server error + status: + $ref: "#/components/schemas/ErrorStatus" + default: 500 + param: + anyOf: + - type: string + - type: "null" + title: Param + type: + anyOf: + - $ref: "#/components/schemas/ErrorContentFilterType" + - type: "null" + type: object + title: Error + ErrorCode: + type: string + enum: + - content_filter + - invalid_authentication + - invalid_authorization + - rate_limit_reached + - exceeded_quota + - internal_server_error + - overloaded + - timeout + - too_large + - unprocessable + - conflict + - Not found + - no_longer_supported + - client_closed_request + title: ErrorCode + ErrorContentFilterType: + type: string + enum: + - safety + - blocklist + - incomplete + title: ErrorContentFilterType + ErrorStatus: + type: integer + enum: + - 400 + - 401 + - 403 + - 429 + - 429 + - 500 + - 503 + - 504 + - 413 + - 422 + - 409 + - 404 + - 410 + - 499 + title: ErrorStatus + ImageGenerationItem-Input: + properties: + b64_json: + type: string + title: B64 Json + description: The base64-encoded JSON of the generated image. + model: + type: string + title: Model + revised: + type: boolean + title: Revised + description: Indicates whether the prompt was revised before generation. + revised_prompt: + type: string + title: Revised Prompt + description: The prompt used for generation, which may be revised from the + original prompt. + type: object + required: + - b64_json + - model + - revised + - revised_prompt + title: ImageGenerationItem + ImageGenerationItem-Output: + properties: + b64_json: + type: string + title: B64 Json + description: The base64-encoded JSON of the generated image. + revised: + type: boolean + title: Revised + description: Indicates whether the prompt was revised before generation. + revised_prompt: + type: string + title: Revised Prompt + description: The prompt used for generation, which may be revised from the + original prompt. + type: object + required: + - b64_json + - revised + - revised_prompt + title: ImageGenerationItem + ImageGenerationModels: + type: string + enum: + - Fanar-Oryx-IG-2 + title: ImageGenerationModels + ImageGenerationRequest: + properties: + model: + $ref: "#/components/schemas/ImageGenerationModels" + description: The model to use for the image generation. + prompt: + type: string + title: Prompt + description: A text description of the desired image. + revise: + type: boolean + title: Revise + description: Whether to automatically revise the prompt to enhance style, + quality, and cultural alignment for improved generation results. + default: true + type: object + required: + - model + - prompt + title: ImageGenerationRequest + example: + model: Fanar-Oryx-IG-2 + prompt: A futuristic cityscape at sunset + ImageGenerationResponse: + properties: + id: + type: string + title: Id + description: A unique identifier for the image generation. + created: + type: integer + title: Created + description: The timestamp of when the image was created. + data: + items: + $ref: "#/components/schemas/ImageGenerationItem-Output" + type: array + title: Data + description: A list of the generated image. + type: object + required: + - id + - created + - data + title: ImageGenerationResponse + ImageURL: + properties: + url: + type: string + title: Url + detail: + type: string + enum: + - auto + - low + - high + title: Detail + type: object + required: + - url + title: ImageURL + LLM: + type: string + enum: + - Fanar-S-1-7B + - Fanar-C-1-8.7B + - Fanar-C-2-27B + title: LLM + MadhabEnum: + type: string + enum: + - all + - hanafi + - maliki + - shafii + - hanbali + title: MadhabEnum + ModelObject: + type: string + enum: + - model + title: ModelObject + ModelsResponse: + properties: + id: + type: string + title: Id + description: A unique identifier for the models. + models: + items: + $ref: "#/components/schemas/AvailableModel" + type: array + title: Models + description: The available models. + type: object + required: + - id + - models + title: ModelsResponse + ModerationModels: + type: string + enum: + - Fanar-Guard-2 + title: ModerationModels + PoemGenerationModels: + type: string + enum: + - Fanar-Diwan + title: PoemGenerationModels + PoemGenerationRequest: + properties: + model: + $ref: "#/components/schemas/PoemGenerationModels" + description: The model to use for the poem generation. + prompt: + type: string + title: Prompt + description: A text description of the desired poem. + type: object + required: + - model + - prompt + title: PoemGenerationRequest + example: + model: Fanar-Diwan + prompt: Write a poem about the sea + PoemGenerationResponse: + properties: + id: + type: string + title: Id + description: A unique identifier for the poem generation. + poem: + type: string + title: Poem + description: The generated poem. + type: object + required: + - id + - poem + title: PoemGenerationResponse + PromptTokensDetails: + additionalProperties: true + properties: + audio_tokens: + anyOf: + - type: integer + - type: "null" + default: null + title: Audio Tokens + cached_tokens: + anyOf: + - type: integer + - type: "null" + default: null + title: Cached Tokens + title: PromptTokensDetails + type: object + QuranReciters: + type: string + enum: + - abdul-basit + - maher-al-muaiqly + - mahmoud-al-husary + title: QuranReciters + STTFormat: + type: string + enum: + - text + - srt + - json + title: STTFormat + STTModels: + type: string + enum: + - Fanar-Aura-STT-1 + - Fanar-Aura-STT-LF-1 + title: STTModels + STTSegement: + properties: + speaker: + type: string + title: Speaker + description: The speaker label for the segment. + start_time: + type: number + title: Start Time + description: The start time of the segment in seconds. + end_time: + type: number + title: End Time + description: The end time of the segment in seconds. + duration: + type: number + title: Duration + description: The duration of the segment in seconds. + text: + type: string + title: Text + description: The transcribed text segment. + type: object + required: + - speaker + - start_time + - end_time + - duration + - text + title: STTSegement + SafetyFilterRequest: + properties: + model: + $ref: "#/components/schemas/ModerationModels" + description: The model to use for safety filtering. + prompt: + type: string + title: Prompt + description: The prompt. + response: + type: string + title: Response + description: The model's response to the prompt. + type: object + required: + - model + - prompt + - response + title: SafetyFilterRequest + example: + model: Fanar-Guard-2 + prompt: What is the weather? + response: The weather is sunny today. + SafetyFilterResponse: + properties: + safety: + type: number + title: Safety + description: The safety score for general safety aspects such as toxicity, + violence, self-harm, etc. + cultural_awareness: + type: number + title: Cultural Awareness + description: The cultural awareness score for aspects such as stereotypes, + insensitive content, and bias. + type: object + required: + - safety + - cultural_awareness + title: SafetyFilterResponse + SourcesEnum: + type: string + enum: + - islam_qa + - islamweb + - islamweb_fatwa + - islamweb_consult + - islamweb_article + - islamweb_library + - sunnah + - quran + - tafsir + - dorar + - islamonline + - shamela + title: SourcesEnum + SpeechToTextResponseJson: + properties: + segments: + items: + $ref: "#/components/schemas/STTSegement" + type: array + title: Segments + description: The list of segments for the transcribed text. + type: object + required: + - segments + title: SpeechToTextResponseJson + SpeechToTextResponseWithJson-Input: + properties: + id: + type: string + title: Id + description: A unique identifier for the speech-to-text. + json: + $ref: "#/components/schemas/SpeechToTextResponseJson" + description: The transcribed text in JSON format. + type: object + required: + - id + - json + title: SpeechToTextResponseWithJson + SpeechToTextResponseWithJson-Output: + properties: + id: + type: string + title: Id + description: A unique identifier for the speech-to-text. + json: + $ref: "#/components/schemas/SpeechToTextResponseJson" + description: The transcribed text in JSON format. + type: object + required: + - id + - json + title: SpeechToTextResponseWithJson + SpeechToTextResponseWithSRT: + properties: + id: + type: string + title: Id + description: A unique identifier for the speech-to-text. + srt: + type: string + title: Srt + description: The transcribed text in SRT format. + type: object + required: + - id + - srt + title: SpeechToTextResponseWithSRT + SpeechToTextResponseWithText: + properties: + id: + type: string + title: Id + description: A unique identifier for the speech-to-text. + text: + type: string + title: Text + description: The transcribed text. + type: object + required: + - id + - text + title: SpeechToTextResponseWithText + TTSModels: + type: string + enum: + - Fanar-Aura-TTS-2 + - Fanar-Sadiq-TTS-1 + title: TTSModels + TTSResponseFormat: + type: string + enum: + - mp3 + - wav + title: TTSResponseFormat + TextToSpeechRequest: + properties: + model: + $ref: "#/components/schemas/TTSModels" + description: The model to use for the text-to-speech. + input: + type: string + title: Input + description: The text to generate audio for. + voice: + type: string + title: Voice + description: |- + The voice to use for the text-to-speech. Details are below: +
VoiceGenderSupported LanguagesAccentEmotion
AbdulrahmanMaleArabicStandard
AmeliaFemaleEnglishBritish
EmilyFemaleEnglishAmerican
HamadMaleArabicStandard
HarryMaleEnglishBritish
HudaFemaleArabicStandard
JakeMaleEnglishAmerican
JasimMaleArabicStandard
NoorFemaleArabicStandard
RadwaFemaleArabicStandard
+ enum: + - Abdulrahman + - Amelia + - Emily + - Hamad + - Harry + - Huda + - Jake + - Jasim + - Noor + - Radwa + response_format: + $ref: "#/components/schemas/TTSResponseFormat" + description: The format of the output audio. Supported formats are `mp3` and + `wav`. + default: mp3 + quran_reciter: + $ref: "#/components/schemas/QuranReciters" + description: The Quran reciter to use when using Fanar-Sadiq-TTS-1 model for + Quranic text. + default: abdul-basit + with_emotion: + type: boolean + title: With Emotion + description: "Enable emotional speech synthesis. **Only applicable to + `Fanar-Aura-TTS-2` and to voices where `emotion: true` in GET + /v1/voices.** When the selected voice does not support emotion, or + when used with `Fanar-Sadiq-TTS-1`, the request is rejected with a + 422 error. Defaults to false." + default: false + stream: + type: boolean + title: Stream + description: Stream the audio as it is generated. Supported for both `wav` and + `mp3`. + default: false + type: object + required: + - model + - input + - voice + title: TextToSpeechRequest + example: + model: Fanar-Aura-TTS-2 + input: Hello, welcome to Fanar! + voice: Harry + TokenizationRequest: + properties: + content: + type: string + title: Content + model: + $ref: "#/components/schemas/LLM" + type: object + required: + - content + - model + title: TokenizationRequest + example: + content: Hello, how are you? + model: Fanar-S-1-7B + TokenizationResponse: + properties: + id: + type: string + title: Id + description: A unique identifier for the tokenization. + tokens: + type: integer + title: Tokens + max_request_tokens: + type: integer + title: Max Request Tokens + type: object + required: + - id + - tokens + - max_request_tokens + title: TokenizationResponse + TopLogprob: + properties: + token: + type: string + title: Token + bytes: + anyOf: + - items: + type: integer + type: array + - type: "null" + title: Bytes + logprob: + type: number + title: Logprob + additionalProperties: true + type: object + required: + - token + - logprob + title: TopLogprob + TranslationLangPairs: + type: string + enum: + - en-ar + - ar-en + title: TranslationLangPairs + TranslationModels: + type: string + enum: + - Fanar-Shaheen-MT-1 + title: TranslationModels + TranslationPreprocessing: + type: string + enum: + - default + - preserve_html + - preserve_whitespace + - preserve_whitespace_and_html + title: TranslationPreprocessing + TranslationRequest: + properties: + model: + $ref: "#/components/schemas/TranslationModels" + description: The model to use for the translation. + text: + type: string + title: Text + description: The text to translate. It must not exceed 4,000 words. + langpair: + $ref: "#/components/schemas/TranslationLangPairs" + description: "The source-target language pair for translation, the current + allowed possible values are:
- en-ar: for English to + Arabic
- ar-en: for Arabic to English" + preprocessing: + anyOf: + - $ref: "#/components/schemas/TranslationPreprocessing" + - type: "null" + description: "How to preprocess the text before translation:
- + default: Splits all sentences by natural punctuation (full + stops, question marks, etc.), trims away extra whitespace, and + removes HTML tags.
- preserve_html: Does the same as + “default”, but tries to preserve HTML tags.
- + preserve_whitespace: Aggressively tries to maintain all extra + leading/trailing whitespaces and joins sentences across newlines to + translate fixed-width content, for example.
- + preserve_whitespace_and_html: Combines the previous two." + default: default + type: object + required: + - model + - text + - langpair + title: TranslationRequest + example: + model: Fanar-Shaheen-MT-1 + text: Hello, how are you? + langpair: en-ar + TranslationResponse: + properties: + id: + type: string + title: Id + description: A unique identifier for the translation. + text: + type: string + title: Text + description: The translated text + type: object + required: + - id + - text + title: TranslationResponse + URL: + properties: + url: + type: string + title: Url + type: object + required: + - url + title: URL + VideoURL: + properties: + url: + type: string + title: Url + type: object + required: + - url + title: VideoURL + Voice: + properties: + name: + type: string + title: Name + description: The English name of the voice. This is the identifier passed to the + TTS endpoint. + examples: + - Amelia + name_ar: + anyOf: + - type: string + - type: "null" + title: Name Ar + description: The Arabic display name of the voice, when available. + examples: + - أميليا + gender: + anyOf: + - type: string + - type: "null" + title: Gender + description: Gender label of the voice (e.g., 'Male', 'Female'). + examples: + - Female + accent: + anyOf: + - type: string + - type: "null" + title: Accent + description: Accent label of the voice (e.g., 'British', 'Gulf', 'American', + 'Standard'). + examples: + - British + languages: + items: + type: string + type: array + title: Languages + description: Supported language codes (e.g., 'en', 'ar'). + examples: + - - en + type: + type: string + enum: + - public + - personal + title: Type + description: Whether this is a built-in public voice or a personalized voice + registered for this API key. + examples: + - public + emotion: + type: boolean + title: Emotion + description: "Whether this voice supports emotional speech synthesis. When true, + you may set `with_emotion: true` on POST /v1/audio/speech to enable + emotional rendering." + default: false + examples: + - true + type: object + required: + - name + - type + title: Voice + example: + accent: British + emotion: false + gender: Female + languages: + - en + name: Amelia + name_ar: أميليا + type: public + VoiceResponse: + properties: + voices: + items: + $ref: "#/components/schemas/Voice" + type: array + title: Voices + description: Available voices. Always includes the built-in public voices. + Includes personalized voices registered for this API key when voice + personalization is authorized. + type: object + required: + - voices + title: VoiceResponse + example: + voices: + - accent: British + emotion: false + gender: Female + languages: + - en + name: Amelia + name_ar: أميليا + type: public + - accent: Gulf + emotion: false + gender: Male + languages: + - ar + name: Hamad + name_ar: حمد + type: public + - emotion: false + languages: [] + name: MyVoice + type: personal + TokenChunk: + properties: + id: + description: A unique identifier for the chat completion + title: Id + type: string + object: + default: chat.completion.chunk + description: The object type + title: Object + type: string + created: + description: The Unix timestamp (in seconds) of when the chat completion was + created. + title: Created + type: integer + model: + description: The model used for the chat completion. + title: Model + type: string + choices: + description: A list of chat completion choices.Can be more than one if `n` is + greater than 1. + items: + $ref: "#/components/schemas/ChoiceToken" + title: Choices + type: array + required: + - id + - created + - model + - choices + title: TokenChunk + type: object + ChoiceToken: + properties: + index: + default: 0 + title: Index + type: integer + finish_reason: + anyOf: + - type: string + - type: "null" + default: null + description: The reason the model stopped generating + title: Finish Reason + delta: + $ref: "#/components/schemas/DeltaContent" + required: + - delta + title: ChoiceToken + type: object + DeltaContent: + properties: + content: + example: Hello + title: Content + type: string + required: + - content + title: DeltaContent + type: object + ToolCallChunk: + properties: + id: + description: A unique identifier for the chat completion + title: Id + type: string + object: + default: chat.completion.chunk + description: The object type + title: Object + type: string + created: + description: The Unix timestamp (in seconds) of when the chat completion was + created. + title: Created + type: integer + model: + description: The model used for the chat completion. + title: Model + type: string + choices: + description: A list of chat completion choices.Can be more than one if `n` is + greater than 1. + items: + $ref: "#/components/schemas/ChoiceToolCall" + title: Choices + type: array + required: + - id + - created + - model + - choices + title: ToolCallChunk + type: object + ChoiceToolCall: + properties: + index: + default: 0 + title: Index + type: integer + finish_reason: + anyOf: + - type: string + - type: "null" + default: null + description: The reason the model stopped generating + title: Finish Reason + delta: + $ref: "#/components/schemas/DeltaToolCalls" + required: + - delta + title: ChoiceToolCall + type: object + DeltaToolCalls: + properties: + tool_calls: + description: The list of tool calls that need to be executed. + items: + $ref: "#/components/schemas/ToolCallData" + title: Tool Calls + type: array + required: + - tool_calls + title: DeltaToolCalls + type: object + FunctionData: + description: Function call details + properties: + name: + description: The name of the function to call + title: Name + type: string + arguments: + description: The arguments to pass to the function as JSON string + title: Arguments + type: string + required: + - name + - arguments + title: FunctionData + type: object + ToolCallData: + description: Tool call in delta.tool_calls + properties: + index: + description: The index of the tool call in the list + title: Index + type: integer + id: + description: The unique identifier for the tool call + title: Id + type: string + type: + default: function + description: The type of the tool call + title: Type + type: string + function: + $ref: "#/components/schemas/FunctionData" + required: + - index + - id + - function + title: ToolCallData + type: object + ToolResultChunk: + properties: + id: + description: A unique identifier for the chat completion + title: Id + type: string + object: + default: chat.completion.chunk + description: The object type + title: Object + type: string + created: + description: The Unix timestamp (in seconds) of when the chat completion was + created. + title: Created + type: integer + model: + description: The model used for the chat completion. + title: Model + type: string + choices: + description: A list of chat completion choices.Can be more than one if `n` is + greater than 1. + items: + $ref: "#/components/schemas/ChoiceToolResult" + title: Choices + type: array + required: + - id + - created + - model + - choices + title: ToolResultChunk + type: object + ChoiceToolResult: + properties: + index: + default: 0 + title: Index + type: integer + finish_reason: + anyOf: + - type: string + - type: "null" + default: null + description: The reason the model stopped generating + title: Finish Reason + delta: + $ref: "#/components/schemas/DeltaToolResult" + required: + - delta + title: ChoiceToolResult + type: object + DeltaToolResult: + properties: + tool_result: + $ref: "#/components/schemas/ToolResultData" + description: The list of tool call results returned by the executed tools. + required: + - tool_result + title: DeltaToolResult + type: object + ToolResultData: + description: Tool result data + properties: + id: + description: The unique identifier for the tool call + title: Id + type: string + name: + anyOf: + - type: string + - type: "null" + default: null + description: The name of the tool called + title: Name + arguments: + additionalProperties: true + description: The arguments passed to the tool + title: Arguments + type: object + result: + anyOf: + - type: string + - type: "null" + default: null + description: The result returned by the tool + title: Result + structured_content: + anyOf: + - additionalProperties: true + type: object + - type: "null" + default: null + description: The structured content returned by the tool + title: Structured Content + is_error: + default: false + description: Indicates if there was an error during the tool call + title: Is Error + type: boolean + required: + - id + title: ToolResultData + type: object + ProgressChunk: + properties: + id: + description: A unique identifier for the chat completion + title: Id + type: string + object: + default: chat.completion.chunk + description: The object type + title: Object + type: string + created: + description: The Unix timestamp (in seconds) of when the chat completion was + created. + title: Created + type: integer + model: + description: The model used for the chat completion. + title: Model + type: string + progress: + $ref: "#/components/schemas/ProgressData" + description: Progress event indicating current processing step + required: + - id + - created + - model + - progress + title: ProgressChunk + type: object + ProgressData: + description: Progress event data + properties: + message: + $ref: "#/components/schemas/ProgressMessage" + description: Bilingual progress message indicating current processing step + required: + - message + title: ProgressData + type: object + ProgressMessage: + description: Bilingual progress message + properties: + en: + description: Progress message in English + title: En + type: string + ar: + description: Progress message in Arabic + title: Ar + type: string + required: + - en + - ar + title: ProgressMessage + type: object + DoneChunk: + properties: + id: + description: A unique identifier for the chat completion + title: Id + type: string + object: + default: chat.completion.chunk + description: The object type + title: Object + type: string + created: + description: The Unix timestamp (in seconds) of when the chat completion was + created. + title: Created + type: integer + model: + description: The model used for the chat completion. + title: Model + type: string + choices: + description: List of choices with final delta + items: + $ref: "#/components/schemas/ChoiceFinal" + title: Choices + type: array + usage: + anyOf: + - $ref: "#/components/schemas/CompletionUsage" + - type: "null" + default: null + description: Usage statistics for the completion request. + metadata: + anyOf: + - additionalProperties: true + type: object + - type: "null" + default: null + description: Additional metadata associated with the completion. + title: Metadata + required: + - id + - created + - model + - choices + title: DoneChunk + type: object + ChoiceFinal: + properties: + index: + default: 0 + title: Index + type: integer + finish_reason: + default: stop + title: Finish Reason + type: string + delta: + $ref: "#/components/schemas/DeltaReferences" + required: + - delta + title: ChoiceFinal + type: object + DeltaReferences: + properties: + references: + anyOf: + - items: + $ref: "#/components/schemas/ChatCompletionChoiceMessageReference" + type: array + - type: "null" + default: null + description: The list of references used in the response + title: References + title: DeltaReferences + type: object + ErrorChunk: + description: Error chunk + properties: + id: + description: A unique identifier for the chat completion + title: Id + type: string + object: + default: chat.completion.chunk + description: The object type + title: Object + type: string + created: + description: The Unix timestamp (in seconds) of when the chat completion was + created. + title: Created + type: integer + model: + description: The model used for the chat completion. + title: Model + type: string + choices: + items: + $ref: "#/components/schemas/ChoiceError" + title: Choices + type: array + required: + - id + - created + - model + - choices + title: ErrorChunk + type: object + ChoiceError: + properties: + index: + default: 0 + title: Index + type: integer + delta: + $ref: "#/components/schemas/DeltaContent" + finish_reason: + default: error + title: Finish Reason + type: string + required: + - delta + title: ChoiceError + type: object + securitySchemes: + Bearer: + type: http + scheme: bearer + description: Provide your API key in the Authorization header using the Bearer scheme. +security: + - Bearer: [] +tags: + - name: Authentication + description: >- + All API endpoints require a **Bearer** token. Include it in the + `Authorization` header: + + + ``` + + Authorization: Bearer YOUR_API_KEY + + ``` diff --git a/core/src/main/java/qa/fanar/core/ErrorCode.java b/core/src/main/java/qa/fanar/core/ErrorCode.java index 727f63c..fa05b4e 100644 --- a/core/src/main/java/qa/fanar/core/ErrorCode.java +++ b/core/src/main/java/qa/fanar/core/ErrorCode.java @@ -55,7 +55,10 @@ public enum ErrorCode { NOT_FOUND("Not found"), /** Feature, model, or endpoint no longer supported. */ - NO_LONGER_SUPPORTED("no_longer_supported"); + NO_LONGER_SUPPORTED("no_longer_supported"), + + /** The client closed the connection before the server finished responding (HTTP 499). */ + CLIENT_CLOSED_REQUEST("client_closed_request"); private final String wireValue; diff --git a/core/src/main/java/qa/fanar/core/FanarClientClosedRequestException.java b/core/src/main/java/qa/fanar/core/FanarClientClosedRequestException.java new file mode 100644 index 0000000..c862f3b --- /dev/null +++ b/core/src/main/java/qa/fanar/core/FanarClientClosedRequestException.java @@ -0,0 +1,22 @@ +package qa.fanar.core; + +/** + * The client closed the connection before Fanar finished responding. Maps to + * {@link ErrorCode#CLIENT_CLOSED_REQUEST} and HTTP 499. + * + *

Typical cause: the caller cancelled or timed out the request mid-flight (for example, a + * subscriber cancelling a stream). The request was abandoned deliberately, so it is not + * retryable.

+ * + * @author Oussama Mahjoub + */ +public final class FanarClientClosedRequestException extends FanarClientException { + + public FanarClientClosedRequestException(String message) { + super(message, ErrorCode.CLIENT_CLOSED_REQUEST, 499); + } + + public FanarClientClosedRequestException(String message, Throwable cause) { + super(message, ErrorCode.CLIENT_CLOSED_REQUEST, 499, cause); + } +} diff --git a/core/src/main/java/qa/fanar/core/FanarClientException.java b/core/src/main/java/qa/fanar/core/FanarClientException.java index cb94bf2..1ae7909 100644 --- a/core/src/main/java/qa/fanar/core/FanarClientException.java +++ b/core/src/main/java/qa/fanar/core/FanarClientException.java @@ -15,7 +15,8 @@ public abstract sealed class FanarClientException extends FanarException permits FanarAuthenticationException, FanarAuthorizationException, FanarQuotaExceededException, FanarNotFoundException, FanarConflictException, FanarTooLargeException, - FanarUnprocessableException, FanarGoneException { + FanarUnprocessableException, FanarGoneException, + FanarClientClosedRequestException { protected FanarClientException(String message, ErrorCode code, int httpStatus) { super(message, code, httpStatus); diff --git a/core/src/main/java/qa/fanar/core/audio/AudioClient.java b/core/src/main/java/qa/fanar/core/audio/AudioClient.java index f6bc0d5..08003bb 100644 --- a/core/src/main/java/qa/fanar/core/audio/AudioClient.java +++ b/core/src/main/java/qa/fanar/core/audio/AudioClient.java @@ -1,22 +1,22 @@ package qa.fanar.core.audio; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; /** * Domain facade for the {@code /v1/audio/*} endpoints. Returned by {@code FanarClient.audio()}. * *

The audio domain has multiple concerns surfaced as flat methods on this single client:

*
    - *
  • {@link #listVoices()} — list user-created voices (built-in voices are - * {@link Voice#KNOWN}; this returns only personalized ones).
  • - *
  • {@link #createVoice(CreateVoiceRequest)} — upload a WAV sample to create a personalized - * voice. Sent as {@code multipart/form-data}.
  • - *
  • {@link #deleteVoice(String)} — remove a personalized voice by name.
  • + *
  • {@link #listVoices()} — the voice catalogue: built-in public voices plus the + * personalized voices registered for the API key.
  • + *
  • {@link #createVoice(CreateVoiceRequest)} / {@link #deleteVoice(String)} — personalized + * voice management. Creation is sent as {@code multipart/form-data}.
  • + *
  • {@link #speech(TextToSpeechRequest)} / {@link #speechStream(TextToSpeechRequest)} — + * TTS, buffered or chunk-streamed binary audio out.
  • + *
  • {@link #transcribe(TranscriptionRequest)} — STT, multipart audio in.
  • *
* - *

Subsequent sub-milestones add {@code speech(...)} (TTS — binary audio out) and - * {@code transcribe(...)} (STT — multipart audio in) to this same interface.

- * *

Implementations must be thread-safe — one {@code AudioClient} instance backs every call on * a given {@code FanarClient}.

* @@ -24,7 +24,10 @@ */ public interface AudioClient { - /** List the user-created (personalized) voices for the current API key. */ + /** + * List the available voices: always the built-in public voices, plus the personalized + * voices registered for the current API key when voice personalization is authorized. + */ VoiceResponse listVoices(); /** Async variant of {@link #listVoices()}. */ @@ -53,6 +56,19 @@ public interface AudioClient { /** Async variant of {@link #speech(TextToSpeechRequest)}. */ CompletableFuture speechAsync(TextToSpeechRequest request); + /** + * Synthesize speech from text, streaming the audio bytes as the server generates them + * (the wire {@code stream:true} mode; supported for both mp3 and wav). + * + *

The returned publisher supports a single subscriber, honours back-pressure, and emits + * opaque {@code byte[]} chunks whose boundaries follow transport reads — concatenate them in + * emission order to reconstruct the full clip. Cancelling the subscription closes the + * underlying connection. The initial request (headers, interceptors, error mapping) behaves + * exactly like {@link #speech(TextToSpeechRequest)}; mid-stream failures surface via + * {@code Subscriber.onError}.

+ */ + Flow.Publisher speechStream(TextToSpeechRequest request); + /** * Transcribe an audio file. The returned {@link SpeechToTextResponse} is one of three * sealed variants — {@link SpeechToTextResponse.Text}, {@link SpeechToTextResponse.Srt}, diff --git a/core/src/main/java/qa/fanar/core/audio/AvailableVoice.java b/core/src/main/java/qa/fanar/core/audio/AvailableVoice.java new file mode 100644 index 0000000..0522fc9 --- /dev/null +++ b/core/src/main/java/qa/fanar/core/audio/AvailableVoice.java @@ -0,0 +1,47 @@ +package qa.fanar.core.audio; + +import java.util.List; +import java.util.Objects; + +/** + * One voice in the {@code GET /v1/audio/voices} listing. + * + *

Mirrors the OpenAPI {@code Voice} schema. Only {@code name} and {@code type} are guaranteed + * by the spec; the descriptive fields are nullable and the {@code languages} list may be empty + * (typical for personalized voices). {@link #name()} is the identifier accepted by + * {@code TextToSpeechRequest.voice()} — convert with {@code Voice.of(availableVoice.name())}.

+ * + *

Named {@code AvailableVoice} (mirroring {@code models.AvailableModel}) because {@link Voice} + * is already the request-side voice identifier.

+ * + * @param name the English voice name — the identifier passed to the TTS endpoint; never + * {@code null} + * @param nameAr the Arabic display name, or {@code null} when unavailable + * @param gender gender label (for example {@code "Male"}, {@code "Female"}), or {@code null} + * @param accent accent label (for example {@code "British"}, {@code "Gulf"}, + * {@code "Standard"}), or {@code null} + * @param languages supported language codes (for example {@code "en"}, {@code "ar"}); + * {@code null} when the server omits the field, defensively copied otherwise + * @param type whether this is a built-in public voice or a personalized one; never + * {@code null} + * @param emotion whether the voice supports emotional synthesis + * ({@code TextToSpeechRequest.withEmotion}) + * + * @author Oussama Mahjoub + */ +public record AvailableVoice( + String name, + String nameAr, + String gender, + String accent, + List languages, + VoiceType type, + boolean emotion +) { + + public AvailableVoice { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(type, "type"); + languages = languages == null ? null : List.copyOf(languages); + } +} diff --git a/core/src/main/java/qa/fanar/core/audio/TextToSpeechRequest.java b/core/src/main/java/qa/fanar/core/audio/TextToSpeechRequest.java index e93f313..0126d08 100644 --- a/core/src/main/java/qa/fanar/core/audio/TextToSpeechRequest.java +++ b/core/src/main/java/qa/fanar/core/audio/TextToSpeechRequest.java @@ -9,7 +9,14 @@ * ({@link Voice#KNOWN}) or personalized voices created via {@code AudioClient.createVoice(...)}. * The {@code quranReciter} field is only meaningful with {@link TtsModel#FANAR_SADIQ_TTS_1}; * the server applies its default ({@link QuranReciter#ABDUL_BASIT}) when omitted with that - * model and ignores the field for {@link TtsModel#FANAR_AURA_TTS_2}.

+ * model and ignores the field for {@link TtsModel#FANAR_AURA_TTS_2}. The {@code withEmotion} + * flag only applies to {@link TtsModel#FANAR_AURA_TTS_2} together with a voice whose + * {@code AvailableVoice.emotion()} is {@code true}; other combinations are rejected server-side + * with HTTP 422 (ADR-015: model-specific rules are Fanar's responsibility).

+ * + *

The wire field {@code stream} is not modelled here. Buffered vs. streamed delivery + * is a call-site choice on the audio facade ({@code speech(request)} vs. + * {@code speechStream(request)}), and the transport sets the wire field accordingly.

* * @param model the TTS model to use; must not be {@code null} * @param input the text to synthesize; must not be {@code null} @@ -17,6 +24,7 @@ * @param responseFormat audio container ({@link TtsResponseFormat#MP3} or * {@link TtsResponseFormat#WAV}); {@code null} → server default (mp3) * @param quranReciter reciter selection for the Sadiq TTS model; {@code null} → server default + * @param withEmotion enable emotional speech synthesis; {@code null} → server default (off) * * @author Oussama Mahjoub */ @@ -25,18 +33,61 @@ public record TextToSpeechRequest( String input, Voice voice, TtsResponseFormat responseFormat, - QuranReciter quranReciter + QuranReciter quranReciter, + Boolean withEmotion ) { public TextToSpeechRequest { Objects.requireNonNull(model, "model"); Objects.requireNonNull(input, "input"); Objects.requireNonNull(voice, "voice"); - // responseFormat + quranReciter nullable — server applies its defaults + // responseFormat + quranReciter + withEmotion nullable — server applies its defaults } - /** Static factory for the common path: model + text + voice, server-default format/reciter. */ + /** Static factory for the common path: model + text + voice, server defaults elsewhere. */ public static TextToSpeechRequest of(TtsModel model, String input, Voice voice) { - return new TextToSpeechRequest(model, input, voice, null, null); + return new TextToSpeechRequest(model, input, voice, null, null, null); + } + + /** Start a fresh builder. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Fluent builder for {@link TextToSpeechRequest}. Every optional field defaults to + * {@code null}, meaning "use the server default". + * + *

{@link #build()} delegates to the record's canonical constructor and therefore runs + * the same validation — missing required fields throw at {@link #build()}, never later.

+ */ + public static final class Builder { + + private TtsModel model; + private String input; + private Voice voice; + private TtsResponseFormat responseFormat; + private QuranReciter quranReciter; + private Boolean withEmotion; + + private Builder() { + // use TextToSpeechRequest.builder() + } + + public Builder model(TtsModel model) { this.model = model; return this; } + public Builder input(String input) { this.input = input; return this; } + public Builder voice(Voice voice) { this.voice = voice; return this; } + public Builder responseFormat(TtsResponseFormat responseFormat) { this.responseFormat = responseFormat; return this; } + public Builder quranReciter(QuranReciter quranReciter) { this.quranReciter = quranReciter; return this; } + public Builder withEmotion(Boolean withEmotion) { this.withEmotion = withEmotion; return this; } + + /** + * Validate and build the {@link TextToSpeechRequest}. + * + * @throws NullPointerException if a required field is missing + */ + public TextToSpeechRequest build() { + return new TextToSpeechRequest(model, input, voice, responseFormat, quranReciter, withEmotion); + } } } diff --git a/core/src/main/java/qa/fanar/core/audio/Voice.java b/core/src/main/java/qa/fanar/core/audio/Voice.java index ef305d9..12bce4a 100644 --- a/core/src/main/java/qa/fanar/core/audio/Voice.java +++ b/core/src/main/java/qa/fanar/core/audio/Voice.java @@ -18,6 +18,9 @@ */ public record Voice(String wireValue) { + /** Male, Standard — Arabic. Supports emotional synthesis ({@code with_emotion}). */ + public static final Voice ABDULRAHMAN = new Voice("Abdulrahman"); + /** Female, British accent — English. */ public static final Voice AMELIA = new Voice("Amelia"); @@ -42,10 +45,13 @@ public record Voice(String wireValue) { /** Female, Standard — Arabic. */ public static final Voice NOOR = new Voice("Noor"); + /** Female, Standard — Arabic. Supports emotional synthesis ({@code with_emotion}). */ + public static final Voice RADWA = new Voice("Radwa"); + /** Snapshot of the SDK's bundled built-in voices. Custom voices created via the API are * outside this set but still valid via {@link #of(String)}. */ public static final Set KNOWN = Set.of( - AMELIA, EMILY, HAMAD, HARRY, HUDA, JAKE, JASIM, NOOR); + ABDULRAHMAN, AMELIA, EMILY, HAMAD, HARRY, HUDA, JAKE, JASIM, NOOR, RADWA); public Voice { Objects.requireNonNull(wireValue, "wireValue"); diff --git a/core/src/main/java/qa/fanar/core/audio/VoiceResponse.java b/core/src/main/java/qa/fanar/core/audio/VoiceResponse.java index ca55e97..098dff5 100644 --- a/core/src/main/java/qa/fanar/core/audio/VoiceResponse.java +++ b/core/src/main/java/qa/fanar/core/audio/VoiceResponse.java @@ -4,17 +4,18 @@ import java.util.Objects; /** - * Response from {@code GET /v1/audio/voices} — the personalized voices created for the API key. + * Response from {@code GET /v1/audio/voices}. * - *

The list contains voice names suitable for use in {@code TextToSpeechRequest.voice()}. - * The 8 built-in voices ({@link Voice#KNOWN}) are not included; this list is solely - * the user-created voices.

+ *

The listing always includes the built-in public voices and additionally the personalized + * voices registered for this API key when voice personalization is authorized. Each entry is a + * rich {@link AvailableVoice}; its {@link AvailableVoice#name() name} is the identifier accepted + * by {@code TextToSpeechRequest.voice()}.

* - * @param voices voice names, defensively copied and unmodifiable + * @param voices the available voices, defensively copied and unmodifiable * * @author Oussama Mahjoub */ -public record VoiceResponse(List voices) { +public record VoiceResponse(List voices) { public VoiceResponse { Objects.requireNonNull(voices, "voices"); diff --git a/core/src/main/java/qa/fanar/core/audio/VoiceType.java b/core/src/main/java/qa/fanar/core/audio/VoiceType.java new file mode 100644 index 0000000..ba95a96 --- /dev/null +++ b/core/src/main/java/qa/fanar/core/audio/VoiceType.java @@ -0,0 +1,37 @@ +package qa.fanar.core.audio; + +import java.util.Objects; +import java.util.Set; + +/** + * Whether a voice in the {@code GET /v1/audio/voices} listing is a built-in public voice or a + * personalized voice registered for the API key. + * + *

Mirrors the {@code type} enum on the OpenAPI {@code Voice} schema — but open: if Fanar adds + * a new voice category, decoding still succeeds via {@link #of(String)} without waiting for an + * SDK release.

+ * + * @param wireValue the exact string Fanar uses on the wire for this voice type + * + * @author Oussama Mahjoub + */ +public record VoiceType(String wireValue) { + + /** A built-in voice available to every API key. */ + public static final VoiceType PUBLIC = new VoiceType("public"); + + /** A personalized voice registered for this API key via {@code AudioClient.createVoice(...)}. */ + public static final VoiceType PERSONAL = new VoiceType("personal"); + + /** Snapshot of the SDK's bundled constants. */ + public static final Set KNOWN = Set.of(PUBLIC, PERSONAL); + + public VoiceType { + Objects.requireNonNull(wireValue, "wireValue"); + } + + /** Equivalent to {@code new VoiceType(wireValue)}; provided for API symmetry with other types. */ + public static VoiceType of(String wireValue) { + return new VoiceType(wireValue); + } +} diff --git a/core/src/main/java/qa/fanar/core/chat/ChatModel.java b/core/src/main/java/qa/fanar/core/chat/ChatModel.java index 50c10b4..661e4cd 100644 --- a/core/src/main/java/qa/fanar/core/chat/ChatModel.java +++ b/core/src/main/java/qa/fanar/core/chat/ChatModel.java @@ -35,12 +35,19 @@ public record ChatModel(String wireValue) { /** Islamic RAG model. Returns authenticated source references. Rate limit 50/min. */ public static final ChatModel FANAR_SADIQ = new ChatModel("Fanar-Sadiq"); + /** + * Madhab-aware Islamic RAG model, version 2. Honours the {@code madhab} request filter; + * extra authorization required. Rate limit 50/min. + */ + public static final ChatModel FANAR_SADIQ_2 = new ChatModel("Fanar-Sadiq-2"); + /** Vision-language model. Arabic-calligraphy-aware. Rate limit 20/day. */ public static final ChatModel FANAR_ORYX_IVU_2 = new ChatModel("Fanar-Oryx-IVU-2"); /** Snapshot of the SDK's bundled constants. Use for iteration, autocomplete catalogues, and tests. */ public static final Set KNOWN = Set.of( - FANAR, FANAR_S_1_7B, FANAR_C_1_8_7B, FANAR_C_2_27B, FANAR_SADIQ, FANAR_ORYX_IVU_2); + FANAR, FANAR_S_1_7B, FANAR_C_1_8_7B, FANAR_C_2_27B, FANAR_SADIQ, FANAR_SADIQ_2, + FANAR_ORYX_IVU_2); public ChatModel { Objects.requireNonNull(wireValue, "wireValue"); diff --git a/core/src/main/java/qa/fanar/core/chat/ChatRequest.java b/core/src/main/java/qa/fanar/core/chat/ChatRequest.java index 90eea7f..f627048 100644 --- a/core/src/main/java/qa/fanar/core/chat/ChatRequest.java +++ b/core/src/main/java/qa/fanar/core/chat/ChatRequest.java @@ -16,8 +16,9 @@ *

Collections are defensively copied on construction and returned as unmodifiable views. * Validation follows ADR-015: required-field non-null, well-defined range checks on numeric * fields, and size limits where the wire spec documents them. Model-specific rules — for - * example, {@code enableThinking} only applying to {@code Fanar-C-2-27B} — are Fanar's - * responsibility; the SDK surfaces the server's rejection via the typed exception hierarchy.

+ * example, {@code enableThinking} only applying to {@code Fanar-C-2-27B}, {@code persona} to + * {@code Fanar-Sadiq}, or {@code madhab} to {@code Fanar-Sadiq-2} — are Fanar's responsibility; + * the SDK surfaces the server's rejection via the typed exception hierarchy.

* *

The wire field {@code stream} is not modelled here. Streaming vs. non-streaming is * a call-site choice on the domain facade (for example {@code client.chat().stream(request)}), @@ -65,7 +66,9 @@ public record ChatRequest( List preferredSources, List excludeSources, List filterSources, - Boolean restrictToIslamic + Boolean restrictToIslamic, + String persona, + List madhab ) { public ChatRequest { @@ -98,6 +101,7 @@ public record ChatRequest( requireStrictlyPositive("repetitionPenalty", repetitionPenalty); requireMinInclusive("truncatePromptTokens", truncatePromptTokens, 1); requireMinInclusive("promptLogprobs", promptLogprobs, 0); + requireMaxLength("persona", persona, 2000); // stop: max 4 entries per the Fanar spec if (stop != null) { @@ -115,6 +119,7 @@ public record ChatRequest( preferredSources = preferredSources == null ? null : List.copyOf(preferredSources); excludeSources = excludeSources == null ? null : List.copyOf(excludeSources); filterSources = filterSources == null ? null : List.copyOf(filterSources); + madhab = madhab == null ? null : List.copyOf(madhab); } private static void requireInRange(String name, Double value, double min, double max) { @@ -145,6 +150,13 @@ private static void requireStrictlyPositive(String name, Double value) { } } + private static void requireMaxLength(String name, String value, int max) { + if (value != null && value.length() > max) { + throw new IllegalArgumentException( + name + " must be at most " + max + " characters, got " + value.length()); + } + } + /** Start a fresh builder. */ public static Builder builder() { return new Builder(); @@ -189,6 +201,8 @@ public static final class Builder { private List excludeSources; private List filterSources; private Boolean restrictToIslamic; + private String persona; + private List madhab; private Builder() { // use ChatRequest.builder() @@ -246,6 +260,8 @@ public Builder messages(List messages) { public Builder excludeSources(List excludeSources) { this.excludeSources = excludeSources; return this; } public Builder filterSources(List filterSources) { this.filterSources = filterSources; return this; } public Builder restrictToIslamic(Boolean restrictToIslamic) { this.restrictToIslamic = restrictToIslamic; return this; } + public Builder persona(String persona) { this.persona = persona; return this; } + public Builder madhab(List madhab) { this.madhab = madhab; return this; } /** * Validate and build the {@link ChatRequest}. @@ -262,7 +278,8 @@ public ChatRequest build() { topK, minP, repetitionPenalty, bestOf, lengthPenalty, earlyStopping, stopTokenIds, ignoreEos, minTokens, skipSpecialTokens, spacesBetweenSpecialTokens, truncatePromptTokens, promptLogprobs, - bookNames, preferredSources, excludeSources, filterSources, restrictToIslamic); + bookNames, preferredSources, excludeSources, filterSources, restrictToIslamic, + persona, madhab); } } } diff --git a/core/src/main/java/qa/fanar/core/chat/Madhab.java b/core/src/main/java/qa/fanar/core/chat/Madhab.java new file mode 100644 index 0000000..4e752b4 --- /dev/null +++ b/core/src/main/java/qa/fanar/core/chat/Madhab.java @@ -0,0 +1,47 @@ +package qa.fanar.core.chat; + +import java.util.Objects; +import java.util.Set; + +/** + * Madhab (Islamic school of jurisprudence) filter for the madhab-aware Islamic RAG model + * ({@code Fanar-Sadiq-2}). + * + *

Mirrors the {@code MadhabEnum} in the Fanar OpenAPI spec — but open: if Fanar adds a new + * school, callers can target it via {@link #of(String)} without waiting for an SDK release. + * Used on the {@code ChatRequest} {@code madhab} field, which only the {@code Fanar-Sadiq-2} + * model honours.

+ * + * @param wireValue the exact string Fanar uses on the wire for this madhab + * + * @author Oussama Mahjoub + */ +public record Madhab(String wireValue) { + + /** No filtering — draw from all schools. */ + public static final Madhab ALL = new Madhab("all"); + + /** The Hanafi school. */ + public static final Madhab HANAFI = new Madhab("hanafi"); + + /** The Maliki school. */ + public static final Madhab MALIKI = new Madhab("maliki"); + + /** The Shafi'i school. */ + public static final Madhab SHAFII = new Madhab("shafii"); + + /** The Hanbali school. */ + public static final Madhab HANBALI = new Madhab("hanbali"); + + /** Snapshot of the SDK's bundled constants. */ + public static final Set KNOWN = Set.of(ALL, HANAFI, MALIKI, SHAFII, HANBALI); + + public Madhab { + Objects.requireNonNull(wireValue, "wireValue"); + } + + /** Equivalent to {@code new Madhab(wireValue)}; provided for API symmetry with other types. */ + public static Madhab of(String wireValue) { + return new Madhab(wireValue); + } +} diff --git a/core/src/main/java/qa/fanar/core/images/ImageGenerationItem.java b/core/src/main/java/qa/fanar/core/images/ImageGenerationItem.java index 3f7bf32..b7f8bfd 100644 --- a/core/src/main/java/qa/fanar/core/images/ImageGenerationItem.java +++ b/core/src/main/java/qa/fanar/core/images/ImageGenerationItem.java @@ -5,18 +5,24 @@ /** * One generated image returned in {@link ImageGenerationResponse#data()}. * - *

Per the OpenAPI spec, the only shape Fanar emits is base64-encoded bytes via + *

Per the OpenAPI spec, the only image shape Fanar emits is base64-encoded bytes via * {@code b64_json}. If Fanar later adds a URL-output variant, this type would become a sealed * hierarchy ({@code ImageGenerationItem} → {@code Base64Item} / {@code UrlItem}); for now the * record stays flat to match what the server actually sends.

* - * @param b64Json base64-encoded image bytes, ready to {@link java.util.Base64#getDecoder() decode} + * @param b64Json base64-encoded image bytes, ready to + * {@link java.util.Base64#getDecoder() decode} + * @param revised whether Fanar revised the prompt before generation (see + * {@link ImageGenerationRequest#revise()}) + * @param revisedPrompt the prompt actually used for generation — equal to the request prompt + * when {@link #revised()} is {@code false} * * @author Oussama Mahjoub */ -public record ImageGenerationItem(String b64Json) { +public record ImageGenerationItem(String b64Json, boolean revised, String revisedPrompt) { public ImageGenerationItem { Objects.requireNonNull(b64Json, "b64Json"); + Objects.requireNonNull(revisedPrompt, "revisedPrompt"); } } diff --git a/core/src/main/java/qa/fanar/core/images/ImageGenerationRequest.java b/core/src/main/java/qa/fanar/core/images/ImageGenerationRequest.java index 95cfc5d..703c903 100644 --- a/core/src/main/java/qa/fanar/core/images/ImageGenerationRequest.java +++ b/core/src/main/java/qa/fanar/core/images/ImageGenerationRequest.java @@ -11,18 +11,24 @@ * * @param model the image-generation model to use; must not be {@code null} * @param prompt a natural-language description of the desired image; must not be {@code null} + * @param revise whether Fanar may automatically revise the prompt to enhance style, quality, + * and cultural alignment. The server default is {@code true} — + * pass {@code false} to keep the prompt verbatim, or {@code null} to accept the + * default. The response reports the outcome via + * {@link ImageGenerationItem#revised()} / {@link ImageGenerationItem#revisedPrompt()} * * @author Oussama Mahjoub */ -public record ImageGenerationRequest(ImageModel model, String prompt) { +public record ImageGenerationRequest(ImageModel model, String prompt, Boolean revise) { public ImageGenerationRequest { Objects.requireNonNull(model, "model"); Objects.requireNonNull(prompt, "prompt"); + // revise nullable — server applies its default (true) } - /** Static factory — argument order matches the JSON wire shape. */ + /** Static factory — argument order matches the JSON wire shape; server-default revision. */ public static ImageGenerationRequest of(ImageModel model, String prompt) { - return new ImageGenerationRequest(model, prompt); + return new ImageGenerationRequest(model, prompt, null); } } diff --git a/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java b/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java index 59f5916..3b1f443 100644 --- a/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java +++ b/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; import java.util.function.Supplier; import qa.fanar.core.FanarTransportException; @@ -30,6 +31,7 @@ import qa.fanar.core.internal.transport.HttpTransport; import qa.fanar.core.internal.transport.InterceptorChainImpl; import qa.fanar.core.internal.transport.MultipartBuilder; +import qa.fanar.core.internal.transport.StreamFlag; import qa.fanar.core.spi.FanarJsonCodec; import qa.fanar.core.spi.FanarObservationAttributes; import qa.fanar.core.spi.Interceptor; @@ -44,6 +46,8 @@ *
  • {@link #listVoices} / {@link #deleteVoice} — JSON in/out, simple {@code GET}/{@code DELETE}.
  • *
  • {@link #createVoice} / {@link #transcribe} — {@code multipart/form-data} body via {@link MultipartBuilder}.
  • *
  • {@link #speech} — JSON request, binary audio response (drain raw bytes, no JSON decode).
  • + *
  • {@link #speechStream} — same request with {@code "stream":true} spliced in; the response + * body is handed to an {@link AudioStreamPublisher} instead of being drained.
  • *
  • {@link #transcribe} — multipart audio upload, sealed JSON response (text / srt / json variant).
  • * * @@ -208,6 +212,28 @@ public CompletableFuture speechAsync(TextToSpeechRequest request) { return supplyAsync("fanar-audio-speech-async-", () -> speech(request)); } + @Override + public Flow.Publisher speechStream(TextToSpeechRequest request) { + Objects.requireNonNull(request, "request"); + try (ObservationHandle obs = observability.start(OP_SPEECH)) { + try { + obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue()); + byte[] body = StreamFlag.inject(encodeJsonBody(request, "TextToSpeechRequest")); + HttpRequest httpReq = applyCommonHeaders(HttpRequest.newBuilder(speechEndpoint), obs) + .header("Content-Type", "application/json") + // Server picks audio/mpeg or audio/wav based on response_format in body. + .header("Accept", "audio/*") + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + HttpResponse response = dispatch(httpReq, speechEndpoint, "POST", obs); + return new AudioStreamPublisher(response.body()); + } catch (RuntimeException e) { + obs.error(e); + throw e; + } + } + } + // --- transcribe (STT) ------------------------------------------------------------------ @Override diff --git a/core/src/main/java/qa/fanar/core/internal/audio/AudioStreamPublisher.java b/core/src/main/java/qa/fanar/core/internal/audio/AudioStreamPublisher.java new file mode 100644 index 0000000..f84a9ef --- /dev/null +++ b/core/src/main/java/qa/fanar/core/internal/audio/AudioStreamPublisher.java @@ -0,0 +1,157 @@ +package qa.fanar.core.internal.audio; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import qa.fanar.core.FanarTransportException; + +/** + * {@link Flow.Publisher} that reads a streamed audio response body on a virtual thread and emits + * one {@code byte[]} per transport read, as the server generates the audio. + * + *

    Structural twin of {@code qa.fanar.core.internal.sse.SseStreamPublisher}, minus frame + * assembly and JSON decoding — audio chunks are opaque bytes in whatever container the request + * selected (mp3 or wav). Chunk boundaries follow transport reads and carry no semantic meaning; + * consumers concatenate them in emission order to reconstruct the full clip.

    + * + *

    Single-subscriber by construction: subscribing twice triggers {@code onError} on the second + * subscriber. The first subscription launches a virtual thread that pulls chunks from the + * underlying {@link InputStream} and honours the subscriber's {@code request(long)} demand + * before every {@code onNext}. Cancellation closes the stream.

    + * + *

    Internal (ADR-018).

    + * + * @author Oussama Mahjoub + */ +public final class AudioStreamPublisher implements Flow.Publisher { + + private static final int CHUNK_SIZE = 8192; + + private final InputStream body; + private final AtomicBoolean subscribed = new AtomicBoolean(); + + public AudioStreamPublisher(InputStream body) { + this.body = Objects.requireNonNull(body, "body"); + } + + @Override + public void subscribe(Flow.Subscriber subscriber) { + Objects.requireNonNull(subscriber, "subscriber"); + if (!subscribed.compareAndSet(false, true)) { + subscriber.onSubscribe(NoopSubscription.INSTANCE); + subscriber.onError(new IllegalStateException( + "AudioStreamPublisher supports a single subscriber")); + return; + } + new Session(subscriber).start(); + } + + private final class Session implements Flow.Subscription { + + private final Flow.Subscriber subscriber; + private final AtomicLong demand = new AtomicLong(); + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final Object demandLock = new Object(); + + Session(Flow.Subscriber subscriber) { + this.subscriber = subscriber; + } + + void start() { + subscriber.onSubscribe(this); + Thread.ofVirtual().name("fanar-audio-stream-", 0).start(this::run); + } + + @Override + public void request(long n) { + if (n <= 0) { + cancelled.set(true); + wake(); + subscriber.onError(new IllegalArgumentException( + "Flow.Subscription.request(n): n must be > 0")); + return; + } + demand.updateAndGet(curr -> { + long sum = curr + n; + return sum < 0 ? Long.MAX_VALUE : sum; + }); + wake(); + } + + @Override + public void cancel() { + cancelled.set(true); + wake(); + closeQuietly(body); + } + + private void wake() { + synchronized (demandLock) { + demandLock.notifyAll(); + } + } + + private void awaitDemand() throws InterruptedException { + synchronized (demandLock) { + while (!cancelled.get() && demand.get() <= 0) { + demandLock.wait(); + } + } + } + + private void run() { + byte[] buffer = new byte[CHUNK_SIZE]; + try { + int read; + while (!cancelled.get() && (read = body.read(buffer)) != -1) { + if (read == 0) { + continue; + } + awaitDemand(); + if (cancelled.get()) { + return; + } + demand.decrementAndGet(); + subscriber.onNext(Arrays.copyOf(buffer, read)); + } + if (!cancelled.get()) { + subscriber.onComplete(); + } + } catch (Throwable t) { + if (cancelled.get()) { + // Torn down from the outside — swallow; the subscriber's terminal signal + // (if any) is its own responsibility. + return; + } + if (t instanceof InterruptedException ie) { + Thread.currentThread().interrupt(); + subscriber.onError(new FanarTransportException("Audio stream interrupted", ie)); + } else { + subscriber.onError(t); + } + } finally { + closeQuietly(body); + } + } + } + + private static void closeQuietly(Closeable c) { + try { + c.close(); + } catch (IOException ignored) { + // Best-effort cleanup — the stream is being torn down either way. + } + } + + private static final class NoopSubscription implements Flow.Subscription { + static final NoopSubscription INSTANCE = new NoopSubscription(); + @Override public void request(long n) { /* no-op: already errored */ } + @Override public void cancel() { /* no-op: already errored */ } + } +} diff --git a/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java b/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java index aa0ec19..86bb185 100644 --- a/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java +++ b/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java @@ -6,7 +6,6 @@ import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -28,6 +27,7 @@ import qa.fanar.core.internal.transport.ExceptionMapper; import qa.fanar.core.internal.transport.HttpTransport; import qa.fanar.core.internal.transport.InterceptorChainImpl; +import qa.fanar.core.internal.transport.StreamFlag; import qa.fanar.core.spi.FanarJsonCodec; import qa.fanar.core.spi.FanarObservationAttributes; import qa.fanar.core.spi.Interceptor; @@ -194,32 +194,7 @@ private byte[] encodeBody(ChatRequest request, boolean streaming) { if (!streaming) { return serialized; } - return injectStreamFlag(serialized); - } - - /** - * Inject {@code "stream":true} as the first property of the serialized {@link ChatRequest} - * JSON object. Handles both {@code {}} (no comma needed) and {@code {"k":v,...}} (comma - * between the injected flag and the existing first key). - */ - private static byte[] injectStreamFlag(byte[] src) { - if (src.length < 2 || src[0] != '{') { - throw new FanarTransportException( - "JSON codec produced an unexpected body shape (non-object or empty)"); - } - byte[] prefix = "{\"stream\":true".getBytes(StandardCharsets.UTF_8); - boolean emptyObject = src.length == 2; // "{}" - int rest = src.length - 1; // everything after the opening '{' - int resultLen = prefix.length + (emptyObject ? 0 : 1) + rest; - byte[] result = new byte[resultLen]; - int pos = 0; - System.arraycopy(prefix, 0, result, pos, prefix.length); - pos += prefix.length; - if (!emptyObject) { - result[pos++] = ','; - } - System.arraycopy(src, 1, result, pos, rest); - return result; + return StreamFlag.inject(serialized); } private ChatResponse decodeResponse(HttpResponse response) { diff --git a/core/src/main/java/qa/fanar/core/internal/transport/ErrorEnvelope.java b/core/src/main/java/qa/fanar/core/internal/transport/ErrorEnvelope.java new file mode 100644 index 0000000..fbd875d --- /dev/null +++ b/core/src/main/java/qa/fanar/core/internal/transport/ErrorEnvelope.java @@ -0,0 +1,228 @@ +package qa.fanar.core.internal.transport; + +/** + * The typed Fanar error envelope: {@code {"error":{"code":"…","message":"…","status":N}}}. + * + *

    Parsed by a small hand-rolled scanner rather than the {@link + * qa.fanar.core.spi.FanarJsonCodec} SPI: codec implementations reflect over target types, and + * this package is deliberately not exported (ADR-018), so a codec running as a JPMS module could + * not access an envelope DTO defined here. The envelope is a three-field, spec-pinned shape; the + * scanner is strict about JSON syntax but any deviation from the expected shape yields + * {@code null}, letting the {@link ExceptionMapper} fall back to HTTP-status routing.

    + * + *

    Internal (ADR-018).

    + * + * @param code the wire value of the error code; never {@code null} (a parse without a code + * yields no envelope) + * @param message the human-readable server message, or {@code null} when absent + * @author Oussama Mahjoub + */ +record ErrorEnvelope(String code, String message) { + + /** + * Parse an error-response body into an envelope. + * + * @param body the raw response body; may be anything (HTML error pages, plain text, blank) + * @return the envelope, or {@code null} when {@code body} is not a well-formed envelope + */ + static ErrorEnvelope tryParse(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + return new Parser(body).parseEnvelope(); + } catch (MalformedException | NumberFormatException e) { + return null; + } + } + + /** Signals any deviation from well-formed envelope JSON. Carries no stack trace. */ + private static final class MalformedException extends RuntimeException { + MalformedException() { + super(null, null, false, false); + } + } + + /** Single-pass, iterative (non-recursive) scanner over the envelope shape. */ + private static final class Parser { + + private final String s; + private int i; + + Parser(String s) { + this.s = s; + } + + ErrorEnvelope parseEnvelope() { + String code = null; + String message = null; + ws(); + expect('{'); + ws(); + if (!consumeIf('}')) { + do { + String key = string(); + ws(); + expect(':'); + ws(); + if (key.equals("error")) { + expect('{'); + ws(); + if (!consumeIf('}')) { + do { + String errorKey = string(); + ws(); + expect(':'); + ws(); + switch (errorKey) { + case "code" -> code = string(); + case "message" -> message = string(); + default -> skipValue(); + } + } while (commaOrEnd('}')); + } + } else { + skipValue(); + } + } while (commaOrEnd('}')); + } + ws(); + if (i != s.length()) { + throw new MalformedException(); + } + return code == null ? null : new ErrorEnvelope(code, message); + } + + private void ws() { + while (i < s.length() && Character.isWhitespace(s.charAt(i))) { + i++; + } + } + + private char peek() { + if (i >= s.length()) { + throw new MalformedException(); + } + return s.charAt(i); + } + + private void expect(char c) { + if (peek() != c) { + throw new MalformedException(); + } + i++; + } + + private boolean consumeIf(char c) { + if (i < s.length() && s.charAt(i) == c) { + i++; + return true; + } + return false; + } + + /** After a member: consumes {@code ','} (more members follow) or {@code close} (done). */ + private boolean commaOrEnd(char close) { + ws(); + if (consumeIf(',')) { + ws(); + return true; + } + expect(close); + return false; + } + + private String string() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (true) { + char c = peek(); + i++; + if (c == '"') { + return sb.toString(); + } + if (c == '\\') { + char escape = peek(); + i++; + switch (escape) { + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + case '/' -> sb.append('/'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 't' -> sb.append('\t'); + case 'u' -> { + if (i + 4 > s.length()) { + throw new MalformedException(); + } + sb.append((char) Integer.parseInt(s.substring(i, i + 4), 16)); + i += 4; + } + default -> throw new MalformedException(); + } + } else { + sb.append(c); + } + } + } + + private void skipValue() { + switch (peek()) { + case '"' -> string(); + case '{' -> skipContainer('{', '}'); + case '[' -> skipContainer('[', ']'); + case 't' -> literal("true"); + case 'f' -> literal("false"); + case 'n' -> literal("null"); + default -> number(); + } + } + + /** + * Skips a balanced container. Counting only {@code open}/{@code close} is sufficient even + * for mixed nesting: the other bracket kind must balance internally, so it never affects + * this pair's depth. Strings are skipped string-aware so brackets inside them don't count. + */ + private void skipContainer(char open, char close) { + expect(open); + int depth = 1; + while (depth > 0) { + char c = peek(); + if (c == '"') { + string(); + continue; + } + i++; + if (c == open) { + depth++; + } else if (c == close) { + depth--; + } + } + } + + private void literal(String expected) { + if (!s.startsWith(expected, i)) { + throw new MalformedException(); + } + i += expected.length(); + } + + /** Consumes a number laxly (any run of number characters); this parser only skips them. */ + private void number() { + int start = i; + while (i < s.length() && isNumberChar(s.charAt(i))) { + i++; + } + if (i == start) { + throw new MalformedException(); + } + } + + private static boolean isNumberChar(char c) { + return (c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E'; + } + } +} diff --git a/core/src/main/java/qa/fanar/core/internal/transport/ExceptionMapper.java b/core/src/main/java/qa/fanar/core/internal/transport/ExceptionMapper.java index 4082e17..bc38907 100644 --- a/core/src/main/java/qa/fanar/core/internal/transport/ExceptionMapper.java +++ b/core/src/main/java/qa/fanar/core/internal/transport/ExceptionMapper.java @@ -6,8 +6,10 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; +import qa.fanar.core.ErrorCode; import qa.fanar.core.FanarAuthenticationException; import qa.fanar.core.FanarAuthorizationException; +import qa.fanar.core.FanarClientClosedRequestException; import qa.fanar.core.FanarConflictException; import qa.fanar.core.FanarContentFilterException; import qa.fanar.core.FanarException; @@ -15,6 +17,7 @@ import qa.fanar.core.FanarInternalServerException; import qa.fanar.core.FanarNotFoundException; import qa.fanar.core.FanarOverloadedException; +import qa.fanar.core.FanarQuotaExceededException; import qa.fanar.core.FanarRateLimitException; import qa.fanar.core.FanarTimeoutException; import qa.fanar.core.FanarTooLargeException; @@ -24,15 +27,16 @@ * Maps an error {@link HttpResponse} (status code ≥ 400) to the matching * {@link FanarException} subtype per ADR-006 and the Fanar OpenAPI spec. * - *

    This first pass distinguishes exceptions by HTTP status only. A later PR will parse the - * typed {@code ErrorCode} from the response body so we can, for example, distinguish - * {@link FanarRateLimitException} (transient) from {@code FanarQuotaExceededException} - * (permanent) — both HTTP 429. For now HTTP 429 always maps to rate-limit, which is the safe - * default (the retry interceptor will give up after the configured attempt count regardless).

    + *

    Routing is two-stage. When the body is a well-formed Fanar error envelope + * ({@code {"error":{"code":…,"message":…,"status":…}}}), the typed {@link ErrorCode} decides the + * subtype — this is what distinguishes {@link FanarQuotaExceededException} (permanent) from + * {@link FanarRateLimitException} (transient), both HTTP 429, and keeps a non-filter 400 from + * masquerading as a {@link FanarContentFilterException}. When the body is anything else (blank, + * HTML from an intermediary, truncated JSON) or carries an unknown code, the HTTP status decides.

    * - *

    Reads and closes the response body. The error message is the body text when non-blank, - * falling back to a canonical status description otherwise. The {@code Retry-After} header is - * honoured for HTTP 429.

    + *

    Reads and closes the response body. The exception message is the envelope's {@code message} + * when present, the raw body text otherwise, falling back to a canonical status description when + * both are blank. The {@code Retry-After} header is honoured for rate-limit errors.

    * *

    Internal (ADR-018).

    * @@ -47,8 +51,34 @@ private ExceptionMapper() { public static FanarException map(HttpResponse response) { int status = response.statusCode(); String body = readBody(response); - String detail = body.isBlank() ? defaultReason(status) : body; + ErrorEnvelope envelope = ErrorEnvelope.tryParse(body); + String detail = detail(envelope, body, status); + ErrorCode code = envelope == null ? null : tryFromWireValue(envelope.code()); + return code != null ? byCode(code, detail, response) : byStatus(status, detail, response); + } + + /** One subtype per {@link ErrorCode} (ADR-006); the server's typed code is authoritative. */ + private static FanarException byCode(ErrorCode code, String detail, HttpResponse response) { + return switch (code) { + case CONTENT_FILTER -> new FanarContentFilterException(detail); + case INVALID_AUTHENTICATION -> new FanarAuthenticationException(detail); + case INVALID_AUTHORIZATION -> new FanarAuthorizationException(detail); + case RATE_LIMIT_REACHED -> new FanarRateLimitException(detail, parseRetryAfter(response)); + case EXCEEDED_QUOTA -> new FanarQuotaExceededException(detail); + case INTERNAL_SERVER_ERROR -> new FanarInternalServerException(detail); + case OVERLOADED -> new FanarOverloadedException(detail); + case TIMEOUT -> new FanarTimeoutException(detail); + case TOO_LARGE -> new FanarTooLargeException(detail); + case UNPROCESSABLE -> new FanarUnprocessableException(detail); + case CONFLICT -> new FanarConflictException(detail); + case NOT_FOUND -> new FanarNotFoundException(detail); + case NO_LONGER_SUPPORTED -> new FanarGoneException(detail); + case CLIENT_CLOSED_REQUEST -> new FanarClientClosedRequestException(detail); + }; + } + + private static FanarException byStatus(int status, String detail, HttpResponse response) { return switch (status) { case 400 -> new FanarContentFilterException(detail); case 401 -> new FanarAuthenticationException(detail); @@ -59,6 +89,7 @@ public static FanarException map(HttpResponse response) { case 413 -> new FanarTooLargeException(detail); case 422 -> new FanarUnprocessableException(detail); case 429 -> new FanarRateLimitException(detail, parseRetryAfter(response)); + case 499 -> new FanarClientClosedRequestException(detail); case 500 -> new FanarInternalServerException(detail); case 503 -> new FanarOverloadedException(detail); case 504 -> new FanarTimeoutException(detail); @@ -66,6 +97,22 @@ public static FanarException map(HttpResponse response) { }; } + private static String detail(ErrorEnvelope envelope, String body, int status) { + if (envelope != null && envelope.message() != null && !envelope.message().isBlank()) { + return envelope.message(); + } + return body.isBlank() ? defaultReason(status) : body; + } + + private static ErrorCode tryFromWireValue(String wireValue) { + try { + return ErrorCode.fromWireValue(wireValue); + } catch (IllegalArgumentException e) { + // A code this SDK version doesn't know (newer server) — fall back to status routing. + return null; + } + } + private static String readBody(HttpResponse response) { try (InputStream in = response.body()) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); @@ -100,6 +147,7 @@ private static String defaultReason(int status) { case 413 -> "Request entity too large"; case 422 -> "Unprocessable entity"; case 429 -> "Rate limit reached"; + case 499 -> "Client closed request"; case 500 -> "Internal server error"; case 503 -> "Service overloaded"; case 504 -> "Upstream timeout"; diff --git a/core/src/main/java/qa/fanar/core/internal/transport/StreamFlag.java b/core/src/main/java/qa/fanar/core/internal/transport/StreamFlag.java new file mode 100644 index 0000000..6d4dd0d --- /dev/null +++ b/core/src/main/java/qa/fanar/core/internal/transport/StreamFlag.java @@ -0,0 +1,53 @@ +package qa.fanar.core.internal.transport; + +import java.nio.charset.StandardCharsets; + +import qa.fanar.core.FanarTransportException; + +/** + * Injects {@code "stream":true} into a serialized request body. + * + *

    Request records deliberately do not model the wire field {@code stream} — buffered vs. + * streamed delivery is a call-site choice on the domain facade (chat {@code send} vs. + * {@code stream}, audio {@code speech} vs. {@code speechStream}), so the flag is spliced into + * the already-encoded JSON instead.

    + * + *

    Internal (ADR-018).

    + * + * @author Oussama Mahjoub + */ +public final class StreamFlag { + + private StreamFlag() { + // not instantiable + } + + /** + * Inject {@code "stream":true} as the first property of the serialized JSON object. + * Handles both {@code {}} (no comma needed) and {@code {"k":v,...}} (comma between the + * injected flag and the existing first key). + * + * @param src the codec-serialized request body; must be a JSON object + * @return a new array with the flag injected + * @throws FanarTransportException if {@code src} is not a JSON object + */ + public static byte[] inject(byte[] src) { + if (src.length < 2 || src[0] != '{') { + throw new FanarTransportException( + "JSON codec produced an unexpected body shape (non-object or empty)"); + } + byte[] prefix = "{\"stream\":true".getBytes(StandardCharsets.UTF_8); + boolean emptyObject = src.length == 2; // "{}" + int rest = src.length - 1; // everything after the opening '{' + int resultLen = prefix.length + (emptyObject ? 0 : 1) + rest; + byte[] result = new byte[resultLen]; + int pos = 0; + System.arraycopy(prefix, 0, result, pos, prefix.length); + pos += prefix.length; + if (!emptyObject) { + result[pos++] = ','; + } + System.arraycopy(src, 1, result, pos, rest); + return result; + } +} diff --git a/core/src/main/resources/META-INF/native-image/qa.fanar/fanar-core/reflect-config.json b/core/src/main/resources/META-INF/native-image/qa.fanar/fanar-core/reflect-config.json index b44b4bc..36490d3 100644 --- a/core/src/main/resources/META-INF/native-image/qa.fanar/fanar-core/reflect-config.json +++ b/core/src/main/resources/META-INF/native-image/qa.fanar/fanar-core/reflect-config.json @@ -30,6 +30,14 @@ "name":"java.util.concurrent.atomic.AtomicReference", "fields":[{"name":"value"}] }, +{ + "name":"qa.fanar.core.audio.AvailableVoice", + "allDeclaredFields":true, + "allRecordComponents":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String","java.util.List","qa.fanar.core.audio.VoiceType","boolean"] }] +}, { "name":"qa.fanar.core.audio.CreateVoiceRequest", "allDeclaredFields":true, @@ -44,7 +52,7 @@ "allRecordComponents":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, - "methods":[{"name":"input","parameterTypes":[] }, {"name":"model","parameterTypes":[] }, {"name":"quranReciter","parameterTypes":[] }, {"name":"responseFormat","parameterTypes":[] }, {"name":"voice","parameterTypes":[] }] + "methods":[{"name":"input","parameterTypes":[] }, {"name":"model","parameterTypes":[] }, {"name":"quranReciter","parameterTypes":[] }, {"name":"responseFormat","parameterTypes":[] }, {"name":"voice","parameterTypes":[] }, {"name":"withEmotion","parameterTypes":[] }] }, { "name":"qa.fanar.core.audio.TranscriptionRequest", @@ -84,7 +92,7 @@ "allRecordComponents":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, - "methods":[{"name":"bestOf","parameterTypes":[] }, {"name":"bookNames","parameterTypes":[] }, {"name":"earlyStopping","parameterTypes":[] }, {"name":"enableThinking","parameterTypes":[] }, {"name":"excludeSources","parameterTypes":[] }, {"name":"filterSources","parameterTypes":[] }, {"name":"frequencyPenalty","parameterTypes":[] }, {"name":"ignoreEos","parameterTypes":[] }, {"name":"lengthPenalty","parameterTypes":[] }, {"name":"logitBias","parameterTypes":[] }, {"name":"logprobs","parameterTypes":[] }, {"name":"maxTokens","parameterTypes":[] }, {"name":"messages","parameterTypes":[] }, {"name":"minP","parameterTypes":[] }, {"name":"minTokens","parameterTypes":[] }, {"name":"model","parameterTypes":[] }, {"name":"n","parameterTypes":[] }, {"name":"preferredSources","parameterTypes":[] }, {"name":"presencePenalty","parameterTypes":[] }, {"name":"promptLogprobs","parameterTypes":[] }, {"name":"repetitionPenalty","parameterTypes":[] }, {"name":"restrictToIslamic","parameterTypes":[] }, {"name":"skipSpecialTokens","parameterTypes":[] }, {"name":"spacesBetweenSpecialTokens","parameterTypes":[] }, {"name":"stop","parameterTypes":[] }, {"name":"stopTokenIds","parameterTypes":[] }, {"name":"temperature","parameterTypes":[] }, {"name":"topK","parameterTypes":[] }, {"name":"topLogprobs","parameterTypes":[] }, {"name":"topP","parameterTypes":[] }, {"name":"truncatePromptTokens","parameterTypes":[] }] + "methods":[{"name":"bestOf","parameterTypes":[] }, {"name":"bookNames","parameterTypes":[] }, {"name":"earlyStopping","parameterTypes":[] }, {"name":"enableThinking","parameterTypes":[] }, {"name":"excludeSources","parameterTypes":[] }, {"name":"filterSources","parameterTypes":[] }, {"name":"frequencyPenalty","parameterTypes":[] }, {"name":"ignoreEos","parameterTypes":[] }, {"name":"lengthPenalty","parameterTypes":[] }, {"name":"logitBias","parameterTypes":[] }, {"name":"logprobs","parameterTypes":[] }, {"name":"madhab","parameterTypes":[] }, {"name":"maxTokens","parameterTypes":[] }, {"name":"messages","parameterTypes":[] }, {"name":"minP","parameterTypes":[] }, {"name":"minTokens","parameterTypes":[] }, {"name":"model","parameterTypes":[] }, {"name":"n","parameterTypes":[] }, {"name":"persona","parameterTypes":[] }, {"name":"preferredSources","parameterTypes":[] }, {"name":"presencePenalty","parameterTypes":[] }, {"name":"promptLogprobs","parameterTypes":[] }, {"name":"repetitionPenalty","parameterTypes":[] }, {"name":"restrictToIslamic","parameterTypes":[] }, {"name":"skipSpecialTokens","parameterTypes":[] }, {"name":"spacesBetweenSpecialTokens","parameterTypes":[] }, {"name":"stop","parameterTypes":[] }, {"name":"stopTokenIds","parameterTypes":[] }, {"name":"temperature","parameterTypes":[] }, {"name":"topK","parameterTypes":[] }, {"name":"topLogprobs","parameterTypes":[] }, {"name":"topP","parameterTypes":[] }, {"name":"truncatePromptTokens","parameterTypes":[] }] }, { "name":"qa.fanar.core.chat.ChatResponse", @@ -169,7 +177,7 @@ "allRecordComponents":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, - "methods":[{"name":"","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":["java.lang.String","boolean","java.lang.String"] }] }, { "name":"qa.fanar.core.images.ImageGenerationRequest", @@ -177,7 +185,7 @@ "allRecordComponents":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, - "methods":[{"name":"model","parameterTypes":[] }, {"name":"prompt","parameterTypes":[] }] + "methods":[{"name":"model","parameterTypes":[] }, {"name":"prompt","parameterTypes":[] }, {"name":"revise","parameterTypes":[] }] }, { "name":"qa.fanar.core.images.ImageGenerationResponse", diff --git a/core/src/test/java/qa/fanar/core/FanarExceptionTest.java b/core/src/test/java/qa/fanar/core/FanarExceptionTest.java index 7783477..b6d3b79 100644 --- a/core/src/test/java/qa/fanar/core/FanarExceptionTest.java +++ b/core/src/test/java/qa/fanar/core/FanarExceptionTest.java @@ -52,7 +52,9 @@ static Stream apiSubtypes() { Arguments.of("FanarRateLimitException", new FanarRateLimitException("msg"), ErrorCode.RATE_LIMIT_REACHED, 429), Arguments.of("FanarContentFilterException", - new FanarContentFilterException("msg"), ErrorCode.CONTENT_FILTER, 400) + new FanarContentFilterException("msg"), ErrorCode.CONTENT_FILTER, 400), + Arguments.of("FanarClientClosedRequestException", + new FanarClientClosedRequestException("msg"), ErrorCode.CLIENT_CLOSED_REQUEST, 499) ); } @@ -153,7 +155,9 @@ static Stream subtypesWithCause() { Arguments.of("FanarRateLimitException", (Function) c -> new FanarRateLimitException("msg-with-cause", Duration.ofSeconds(1), c)), Arguments.of("FanarContentFilterException", - (Function) c -> new FanarContentFilterException("msg-with-cause", ContentFilterType.SAFETY, c)) + (Function) c -> new FanarContentFilterException("msg-with-cause", ContentFilterType.SAFETY, c)), + Arguments.of("FanarClientClosedRequestException", + (Function) c -> new FanarClientClosedRequestException("msg-with-cause", c)) ); } } diff --git a/core/src/test/java/qa/fanar/core/audio/AvailableVoiceTest.java b/core/src/test/java/qa/fanar/core/audio/AvailableVoiceTest.java new file mode 100644 index 0000000..b2ec471 --- /dev/null +++ b/core/src/test/java/qa/fanar/core/audio/AvailableVoiceTest.java @@ -0,0 +1,68 @@ +package qa.fanar.core.audio; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AvailableVoiceTest { + + @Test + void holdsAllFields() { + AvailableVoice v = new AvailableVoice( + "Amelia", "أميليا", "Female", "British", List.of("en"), VoiceType.PUBLIC, false); + assertEquals("Amelia", v.name()); + assertEquals("أميليا", v.nameAr()); + assertEquals("Female", v.gender()); + assertEquals("British", v.accent()); + assertEquals(List.of("en"), v.languages()); + assertEquals(VoiceType.PUBLIC, v.type()); + assertEquals(false, v.emotion()); + } + + @Test + void descriptiveFieldsAreNullable() { + // Personalized voices in the spec example carry only name / languages / type / emotion. + AvailableVoice v = new AvailableVoice( + "MyVoice", null, null, null, List.of(), VoiceType.PERSONAL, false); + assertNull(v.nameAr()); + assertNull(v.gender()); + assertNull(v.accent()); + assertTrue(v.languages().isEmpty()); + } + + @Test + void nullLanguagesStaysNull() { + AvailableVoice v = new AvailableVoice( + "MyVoice", null, null, null, null, VoiceType.PERSONAL, false); + assertNull(v.languages()); + } + + @Test + void rejectsNullName() { + assertThrows(NullPointerException.class, () -> new AvailableVoice( + null, null, null, null, List.of(), VoiceType.PUBLIC, false)); + } + + @Test + void rejectsNullType() { + assertThrows(NullPointerException.class, () -> new AvailableVoice( + "Amelia", null, null, null, List.of(), null, false)); + } + + @Test + void languagesIsDefensivelyCopiedAndUnmodifiable() { + List src = new ArrayList<>(List.of("en")); + AvailableVoice v = new AvailableVoice( + "Amelia", null, null, null, src, VoiceType.PUBLIC, true); + src.add("ar"); + assertEquals(1, v.languages().size()); + assertThrows(UnsupportedOperationException.class, () -> v.languages().add("ar")); + assertEquals(true, v.emotion()); + } +} diff --git a/core/src/test/java/qa/fanar/core/audio/TextToSpeechRequestTest.java b/core/src/test/java/qa/fanar/core/audio/TextToSpeechRequestTest.java index 32dc018..8bf1b68 100644 --- a/core/src/test/java/qa/fanar/core/audio/TextToSpeechRequestTest.java +++ b/core/src/test/java/qa/fanar/core/audio/TextToSpeechRequestTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -12,12 +13,13 @@ class TextToSpeechRequestTest { void holdsAllFields() { TextToSpeechRequest r = new TextToSpeechRequest( TtsModel.FANAR_AURA_TTS_2, "hello", Voice.HARRY, - TtsResponseFormat.WAV, QuranReciter.ABDUL_BASIT); + TtsResponseFormat.WAV, QuranReciter.ABDUL_BASIT, true); assertEquals(TtsModel.FANAR_AURA_TTS_2, r.model()); assertEquals("hello", r.input()); assertEquals(Voice.HARRY, r.voice()); assertEquals(TtsResponseFormat.WAV, r.responseFormat()); assertEquals(QuranReciter.ABDUL_BASIT, r.quranReciter()); + assertEquals(true, r.withEmotion()); } @Test @@ -29,23 +31,69 @@ void ofLeavesOptionalsNull() { assertEquals(Voice.HARRY, r.voice()); assertNull(r.responseFormat()); assertNull(r.quranReciter()); + assertNull(r.withEmotion()); } @Test void rejectsNullModel() { assertThrows(NullPointerException.class, - () -> new TextToSpeechRequest(null, "t", Voice.HARRY, null, null)); + () -> new TextToSpeechRequest(null, "t", Voice.HARRY, null, null, null)); } @Test void rejectsNullInput() { assertThrows(NullPointerException.class, - () -> new TextToSpeechRequest(TtsModel.FANAR_AURA_TTS_2, null, Voice.HARRY, null, null)); + () -> new TextToSpeechRequest(TtsModel.FANAR_AURA_TTS_2, null, Voice.HARRY, null, null, null)); } @Test void rejectsNullVoice() { assertThrows(NullPointerException.class, - () -> new TextToSpeechRequest(TtsModel.FANAR_AURA_TTS_2, "t", null, null, null)); + () -> new TextToSpeechRequest(TtsModel.FANAR_AURA_TTS_2, "t", null, null, null, null)); + } + + // --- Builder -------------------------------------------------------------------------- + + @Test + void builderReturnsFreshInstance() { + assertNotSame(TextToSpeechRequest.builder(), TextToSpeechRequest.builder()); + } + + @Test + void builderAllFieldsRoundtrip() { + TextToSpeechRequest r = TextToSpeechRequest.builder() + .model(TtsModel.FANAR_SADIQ_TTS_1) + .input("bismillah") + .voice(Voice.RADWA) + .responseFormat(TtsResponseFormat.MP3) + .quranReciter(QuranReciter.MAHER_AL_MUAIQLY) + .withEmotion(false) + .build(); + assertEquals(TtsModel.FANAR_SADIQ_TTS_1, r.model()); + assertEquals("bismillah", r.input()); + assertEquals(Voice.RADWA, r.voice()); + assertEquals(TtsResponseFormat.MP3, r.responseFormat()); + assertEquals(QuranReciter.MAHER_AL_MUAIQLY, r.quranReciter()); + assertEquals(false, r.withEmotion()); + } + + @Test + void builderLeavesUnsetOptionalsNull() { + TextToSpeechRequest r = TextToSpeechRequest.builder() + .model(TtsModel.FANAR_AURA_TTS_2) + .input("hello") + .voice(Voice.AMELIA) + .build(); + assertNull(r.responseFormat()); + assertNull(r.quranReciter()); + assertNull(r.withEmotion()); + } + + @Test + void builderValidationDelegatesToCanonicalConstructor() { + assertThrows(NullPointerException.class, () -> TextToSpeechRequest.builder() + .input("hello") + .voice(Voice.AMELIA) + .build()); } } diff --git a/core/src/test/java/qa/fanar/core/audio/VoiceResponseTest.java b/core/src/test/java/qa/fanar/core/audio/VoiceResponseTest.java index c6f818d..29cd7cd 100644 --- a/core/src/test/java/qa/fanar/core/audio/VoiceResponseTest.java +++ b/core/src/test/java/qa/fanar/core/audio/VoiceResponseTest.java @@ -11,11 +11,16 @@ class VoiceResponseTest { + private static AvailableVoice voice(String name, VoiceType type) { + return new AvailableVoice(name, null, null, null, List.of(), type, false); + } + @Test void holdsList() { - VoiceResponse r = new VoiceResponse(List.of("alice", "bob")); + VoiceResponse r = new VoiceResponse(List.of( + voice("Amelia", VoiceType.PUBLIC), voice("MyVoice", VoiceType.PERSONAL))); assertEquals(2, r.voices().size()); - assertEquals("alice", r.voices().getFirst()); + assertEquals("Amelia", r.voices().getFirst().name()); } @Test @@ -25,12 +30,13 @@ void rejectsNullList() { @Test void listIsDefensivelyCopiedAndUnmodifiable() { - List src = new ArrayList<>(); - src.add("alice"); + List src = new ArrayList<>(); + src.add(voice("Amelia", VoiceType.PUBLIC)); VoiceResponse r = new VoiceResponse(src); - src.add("bob"); + src.add(voice("Hamad", VoiceType.PUBLIC)); assertEquals(1, r.voices().size()); assertNotSame(src, r.voices()); - assertThrows(UnsupportedOperationException.class, () -> r.voices().add("carol")); + assertThrows(UnsupportedOperationException.class, () -> + r.voices().add(voice("Noor", VoiceType.PUBLIC))); } } diff --git a/core/src/test/java/qa/fanar/core/audio/VoiceTest.java b/core/src/test/java/qa/fanar/core/audio/VoiceTest.java index 75aa6f2..2aaa6c2 100644 --- a/core/src/test/java/qa/fanar/core/audio/VoiceTest.java +++ b/core/src/test/java/qa/fanar/core/audio/VoiceTest.java @@ -31,7 +31,8 @@ void rejectsNullWireValue() { @Test void knownContainsBundledConstants() { - assertEquals(8, Voice.KNOWN.size()); + assertEquals(10, Voice.KNOWN.size()); + assertTrue(Voice.KNOWN.contains(Voice.ABDULRAHMAN)); assertTrue(Voice.KNOWN.contains(Voice.AMELIA)); assertTrue(Voice.KNOWN.contains(Voice.EMILY)); assertTrue(Voice.KNOWN.contains(Voice.HAMAD)); @@ -40,5 +41,6 @@ void knownContainsBundledConstants() { assertTrue(Voice.KNOWN.contains(Voice.JAKE)); assertTrue(Voice.KNOWN.contains(Voice.JASIM)); assertTrue(Voice.KNOWN.contains(Voice.NOOR)); + assertTrue(Voice.KNOWN.contains(Voice.RADWA)); } } diff --git a/core/src/test/java/qa/fanar/core/audio/VoiceTypeTest.java b/core/src/test/java/qa/fanar/core/audio/VoiceTypeTest.java new file mode 100644 index 0000000..20c9820 --- /dev/null +++ b/core/src/test/java/qa/fanar/core/audio/VoiceTypeTest.java @@ -0,0 +1,35 @@ +package qa.fanar.core.audio; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class VoiceTypeTest { + + @Test + void knownConstantsRoundtripThroughOf() { + for (VoiceType t : VoiceType.KNOWN) { + assertEquals(t, VoiceType.of(t.wireValue())); + } + } + + @Test + void ofIsLenientOnUnknownValues() { + VoiceType custom = VoiceType.of("organizational"); + assertEquals("organizational", custom.wireValue()); + assertFalse(VoiceType.KNOWN.contains(custom)); + } + + @Test + void rejectsNullWireValue() { + assertThrows(NullPointerException.class, () -> new VoiceType(null)); + assertThrows(NullPointerException.class, () -> VoiceType.of(null)); + } + + @Test + void knownContainsAllConstants() { + assertEquals(2, VoiceType.KNOWN.size()); + } +} diff --git a/core/src/test/java/qa/fanar/core/chat/ChatModelTest.java b/core/src/test/java/qa/fanar/core/chat/ChatModelTest.java index 2424961..f190dfe 100644 --- a/core/src/test/java/qa/fanar/core/chat/ChatModelTest.java +++ b/core/src/test/java/qa/fanar/core/chat/ChatModelTest.java @@ -32,8 +32,9 @@ void rejectsNullWireValue() { @Test void knownContainsAllConstants() { - assertEquals(6, ChatModel.KNOWN.size()); + assertEquals(7, ChatModel.KNOWN.size()); assertTrue(ChatModel.KNOWN.contains(ChatModel.FANAR)); + assertTrue(ChatModel.KNOWN.contains(ChatModel.FANAR_SADIQ_2)); assertTrue(ChatModel.KNOWN.contains(ChatModel.FANAR_ORYX_IVU_2)); } diff --git a/core/src/test/java/qa/fanar/core/chat/ChatRequestTest.java b/core/src/test/java/qa/fanar/core/chat/ChatRequestTest.java index 4c2d6c8..d13d381 100644 --- a/core/src/test/java/qa/fanar/core/chat/ChatRequestTest.java +++ b/core/src/test/java/qa/fanar/core/chat/ChatRequestTest.java @@ -26,11 +26,13 @@ void canonicalConstructorWithOnlyRequiredFields() { null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null); assertEquals(1, r.messages().size()); assertEquals(ChatModel.FANAR, r.model()); assertNull(r.temperature()); assertNull(r.restrictToIslamic()); + assertNull(r.persona()); + assertNull(r.madhab()); } @Test @@ -166,6 +168,13 @@ void stopMaxFourEntries() { base().stop(List.of("a", "b", "c", "d", "e")).build()); } + @Test + void personaMaxLengthBoundaries() { + base().persona("a".repeat(2000)).build(); + assertThrows(IllegalArgumentException.class, () -> + base().persona("a".repeat(2001)).build()); + } + // --- defensive copies + unmodifiable accessors ----------------------------------------- @Test @@ -252,6 +261,15 @@ void filterSourcesIsDefensivelyCopiedAndUnmodifiable() { r.filterSources().add(Source.ISLAMWEB_LIBRARY)); } + @Test + void madhabIsDefensivelyCopiedAndUnmodifiable() { + List src = new ArrayList<>(List.of(Madhab.HANAFI)); + ChatRequest r = base().madhab(src).build(); + src.add(Madhab.MALIKI); + assertEquals(1, r.madhab().size()); + assertThrows(UnsupportedOperationException.class, () -> r.madhab().add(Madhab.SHAFII)); + } + @Test void defensiveCopyReplacesReferenceForMessages() { List src = new ArrayList<>(); @@ -262,7 +280,7 @@ void defensiveCopyReplacesReferenceForMessages() { null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null); assertNotSame(src, r.messages()); } @@ -278,6 +296,7 @@ void nullOptionalCollectionsStayNull() { assertNull(r.preferredSources()); assertNull(r.excludeSources()); assertNull(r.filterSources()); + assertNull(r.madhab()); } // --- Builder ------------------------------------------------------------------------- @@ -364,6 +383,8 @@ void builderAllFieldsRoundtrip() { .excludeSources(List.of(Source.DORAR)) .filterSources(List.of(Source.ISLAMWEB)) .restrictToIslamic(true) + .persona("You are a warm, patient teacher.") + .madhab(List.of(Madhab.HANAFI, Madhab.MALIKI)) .build(); assertEquals(2, r.messages().size()); @@ -397,6 +418,8 @@ void builderAllFieldsRoundtrip() { assertEquals(List.of(Source.DORAR), r.excludeSources()); assertEquals(List.of(Source.ISLAMWEB), r.filterSources()); assertEquals(true, r.restrictToIslamic()); + assertEquals("You are a warm, patient teacher.", r.persona()); + assertEquals(List.of(Madhab.HANAFI, Madhab.MALIKI), r.madhab()); } @Test @@ -421,6 +444,6 @@ private static ChatRequest buildWithMessages(List msgs) { null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null); } } diff --git a/core/src/test/java/qa/fanar/core/chat/MadhabTest.java b/core/src/test/java/qa/fanar/core/chat/MadhabTest.java new file mode 100644 index 0000000..bf98bea --- /dev/null +++ b/core/src/test/java/qa/fanar/core/chat/MadhabTest.java @@ -0,0 +1,35 @@ +package qa.fanar.core.chat; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MadhabTest { + + @Test + void knownConstantsRoundtripThroughOf() { + for (Madhab m : Madhab.KNOWN) { + assertEquals(m, Madhab.of(m.wireValue())); + } + } + + @Test + void ofIsLenientOnUnknownValues() { + Madhab custom = Madhab.of("zahiri"); + assertEquals("zahiri", custom.wireValue()); + assertFalse(Madhab.KNOWN.contains(custom)); + } + + @Test + void rejectsNullWireValue() { + assertThrows(NullPointerException.class, () -> new Madhab(null)); + assertThrows(NullPointerException.class, () -> Madhab.of(null)); + } + + @Test + void knownContainsAllConstants() { + assertEquals(5, Madhab.KNOWN.size()); + } +} diff --git a/core/src/test/java/qa/fanar/core/images/ImageGenerationItemTest.java b/core/src/test/java/qa/fanar/core/images/ImageGenerationItemTest.java index b3366d8..ba5113e 100644 --- a/core/src/test/java/qa/fanar/core/images/ImageGenerationItemTest.java +++ b/core/src/test/java/qa/fanar/core/images/ImageGenerationItemTest.java @@ -9,12 +9,17 @@ class ImageGenerationItemTest { @Test void holdsBase64Field() { - ImageGenerationItem item = new ImageGenerationItem("aGVsbG8="); + ImageGenerationItem item = new ImageGenerationItem("aGVsbG8=", true, "a refined sunset"); assertEquals("aGVsbG8=", item.b64Json()); } @Test void rejectsNullBase64() { - assertThrows(NullPointerException.class, () -> new ImageGenerationItem(null)); + assertThrows(NullPointerException.class, () -> new ImageGenerationItem(null, false, "p")); + } + + @Test + void rejectsNullRevisedPrompt() { + assertThrows(NullPointerException.class, () -> new ImageGenerationItem("aGVsbG8=", false, null)); } } diff --git a/core/src/test/java/qa/fanar/core/images/ImageGenerationRequestTest.java b/core/src/test/java/qa/fanar/core/images/ImageGenerationRequestTest.java index 671db9f..0ffb3a6 100644 --- a/core/src/test/java/qa/fanar/core/images/ImageGenerationRequestTest.java +++ b/core/src/test/java/qa/fanar/core/images/ImageGenerationRequestTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; class ImageGenerationRequestTest { @@ -10,27 +11,30 @@ class ImageGenerationRequestTest { @Test void holdsAllFields() { ImageGenerationRequest r = new ImageGenerationRequest( - ImageModel.FANAR_ORYX_IG_2, "A futuristic cityscape at sunset"); + ImageModel.FANAR_ORYX_IG_2, "A futuristic cityscape at sunset", false); assertEquals(ImageModel.FANAR_ORYX_IG_2, r.model()); assertEquals("A futuristic cityscape at sunset", r.prompt()); + assertEquals(false, r.revise()); } @Test - void ofIsEquivalentToCanonicalConstructor() { - ImageGenerationRequest a = new ImageGenerationRequest(ImageModel.FANAR_ORYX_IG_2, "p"); + void ofIsEquivalentToCanonicalConstructorWithServerDefaultRevision() { + ImageGenerationRequest a = new ImageGenerationRequest(ImageModel.FANAR_ORYX_IG_2, "p", null); ImageGenerationRequest b = ImageGenerationRequest.of(ImageModel.FANAR_ORYX_IG_2, "p"); assertEquals(a, b); + // null → omitted on the wire → the server applies its default (revise=true). + assertNull(b.revise()); } @Test void rejectsNullModel() { assertThrows(NullPointerException.class, - () -> new ImageGenerationRequest(null, "p")); + () -> new ImageGenerationRequest(null, "p", null)); } @Test void rejectsNullPrompt() { assertThrows(NullPointerException.class, - () -> new ImageGenerationRequest(ImageModel.FANAR_ORYX_IG_2, null)); + () -> new ImageGenerationRequest(ImageModel.FANAR_ORYX_IG_2, null, null)); } } diff --git a/core/src/test/java/qa/fanar/core/images/ImageGenerationResponseTest.java b/core/src/test/java/qa/fanar/core/images/ImageGenerationResponseTest.java index 8e98599..0ad664b 100644 --- a/core/src/test/java/qa/fanar/core/images/ImageGenerationResponseTest.java +++ b/core/src/test/java/qa/fanar/core/images/ImageGenerationResponseTest.java @@ -13,7 +13,7 @@ class ImageGenerationResponseTest { @Test void holdsAllFields() { - ImageGenerationItem item = new ImageGenerationItem("aGVsbG8="); + ImageGenerationItem item = new ImageGenerationItem("aGVsbG8=", false, "p"); ImageGenerationResponse r = new ImageGenerationResponse( "req_1", 1_700_000_000L, List.of(item)); assertEquals("req_1", r.id()); @@ -37,12 +37,12 @@ void rejectsNullData() { @Test void dataListIsDefensivelyCopiedAndUnmodifiable() { List src = new ArrayList<>(); - src.add(new ImageGenerationItem("a")); + src.add(new ImageGenerationItem("a", false, "p")); ImageGenerationResponse r = new ImageGenerationResponse("req", 0L, src); - src.add(new ImageGenerationItem("b")); + src.add(new ImageGenerationItem("b", false, "p")); assertEquals(1, r.data().size()); assertNotSame(src, r.data()); assertThrows(UnsupportedOperationException.class, - () -> r.data().add(new ImageGenerationItem("c"))); + () -> r.data().add(new ImageGenerationItem("c", false, "p"))); } } diff --git a/core/src/test/java/qa/fanar/core/internal/audio/AudioClientImplTest.java b/core/src/test/java/qa/fanar/core/internal/audio/AudioClientImplTest.java index f0922a2..f82852b 100644 --- a/core/src/test/java/qa/fanar/core/internal/audio/AudioClientImplTest.java +++ b/core/src/test/java/qa/fanar/core/internal/audio/AudioClientImplTest.java @@ -1,6 +1,7 @@ package qa.fanar.core.internal.audio; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -29,6 +30,7 @@ import qa.fanar.core.FanarAuthenticationException; import qa.fanar.core.FanarTransportException; import qa.fanar.core.RetryPolicy; +import qa.fanar.core.audio.AvailableVoice; import qa.fanar.core.audio.CreateVoiceRequest; import qa.fanar.core.audio.SpeechToTextResponse; import qa.fanar.core.audio.SttFormat; @@ -39,6 +41,7 @@ import qa.fanar.core.audio.TtsResponseFormat; import qa.fanar.core.audio.Voice; import qa.fanar.core.audio.VoiceResponse; +import qa.fanar.core.audio.VoiceType; import qa.fanar.core.internal.transport.HttpTransport; import qa.fanar.core.spi.FanarJsonCodec; import qa.fanar.core.spi.Interceptor; @@ -61,7 +64,7 @@ class AudioClientImplTest { @Test void listVoicesHappyPathDecodesResponse() { - VoiceResponse canned = new VoiceResponse(List.of("alice")); + VoiceResponse canned = new VoiceResponse(List.of(publicVoice("alice"))); HttpTransport transport = req -> httpResponse(200, "{}", Map.of()); AudioClientImpl client = build(transport, cannedListCodec(canned), List.of()); assertSame(canned, client.listVoices()); @@ -143,7 +146,7 @@ public void encode(OutputStream s, Object v) { /* unused */ } @Test void listVoicesAsyncCompletesSuccessfully() throws Exception { - VoiceResponse canned = new VoiceResponse(List.of("alice")); + VoiceResponse canned = new VoiceResponse(List.of(publicVoice("alice"))); HttpTransport transport = req -> httpResponse(200, "{}", Map.of()); AudioClientImpl client = build(transport, cannedListCodec(canned), List.of()); CompletableFuture f = client.listVoicesAsync(); @@ -485,6 +488,75 @@ void speechAsyncCompletesExceptionally() { assertInstanceOf(FanarAuthenticationException.class, ex.getCause()); } + // --- speechStream (streamed TTS) ------------------------------------------------------- + + @Test + void speechStreamSplicesStreamFlagAndKeepsAudioAccept() throws Exception { + AtomicReference captured = new AtomicReference<>(); + HttpTransport transport = req -> { captured.set(req); return binaryResponse(200, new byte[]{1}); }; + FanarJsonCodec markerCodec = new FanarJsonCodec() { + public T decode(InputStream s, Class t) { + throw new AssertionError("decode should not be called for binary response"); + } + public void encode(OutputStream s, Object v) throws IOException { + s.write("{\"marker\":true}".getBytes(StandardCharsets.UTF_8)); + } + }; + build(transport, markerCodec, List.of()).speechStream( + TextToSpeechRequest.of(TtsModel.FANAR_AURA_TTS_2, "hello", Voice.HARRY)); + + HttpRequest sent = captured.get(); + assertEquals("POST", sent.method()); + assertEquals("/v1/audio/speech", sent.uri().getPath()); + assertEquals(Optional.of("application/json"), sent.headers().firstValue("Content-Type")); + assertEquals(Optional.of("audio/*"), sent.headers().firstValue("Accept")); + assertEquals("{\"stream\":true,\"marker\":true}", bodyOf(sent)); + } + + @Test + void speechStreamDeliversChunksThatConcatenateToTheResponseBody() throws Exception { + byte[] audioBytes = new byte[]{(byte) 0xff, (byte) 0xfb, 0x10, 0x00, 42}; + HttpTransport transport = req -> binaryResponse(200, audioBytes); + Flow.Publisher publisher = build(transport, encodingCodec(), List.of()) + .speechStream(TextToSpeechRequest.of(TtsModel.FANAR_AURA_TTS_2, "hi", Voice.HARRY)); + + ByteArrayOutputStream collected = new ByteArrayOutputStream(); + CountDownLatch done = new CountDownLatch(1); + publisher.subscribe(new Flow.Subscriber() { + public void onSubscribe(Flow.Subscription s) { s.request(Long.MAX_VALUE); } + public void onNext(byte[] item) { collected.writeBytes(item); } + public void onError(Throwable t) { done.countDown(); } + public void onComplete() { done.countDown(); } + }); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertArrayEquals(audioBytes, collected.toByteArray()); + } + + @Test + void speechStreamMapsErrorStatusBeforeStreaming() { + // The 422 surfaces synchronously from speechStream — no publisher is created for an + // error response (e.g. with_emotion on a non-capable voice). + HttpTransport transport = req -> httpResponse(422, "", Map.of()); + AudioClientImpl client = build(transport, encodingCodec(), List.of()); + assertThrows(qa.fanar.core.FanarUnprocessableException.class, () -> client.speechStream( + TextToSpeechRequest.of(TtsModel.FANAR_AURA_TTS_2, "hi", Voice.HARRY))); + } + + @Test + void speechStreamOpensSpeechObservation() { + AtomicReference opened = new AtomicReference<>(); + ObservabilityPlugin plugin = name -> { + opened.set(name); + return errorObs(new AtomicInteger()); + }; + HttpTransport transport = req -> binaryResponse(200, new byte[]{0}); + AudioClientImpl client = new AudioClientImpl( + BASE, encodingCodec(), () -> "t", List.of(), transport, + plugin, RetryPolicy.disabled(), Map.of(), null); + client.speechStream(TextToSpeechRequest.of(TtsModel.FANAR_AURA_TTS_2, "hi", Voice.HARRY)); + assertEquals("fanar.audio.speech", opened.get()); + } + @Test void speechObservationOpensAndAttributesIncludeModel() { AtomicReference opened = new AtomicReference<>(); @@ -533,7 +605,7 @@ void speechRequestUsesTtsResponseFormatWavWhenSet() throws Exception { HttpTransport transport = req -> { captured.set(req); return binaryResponse(200, new byte[]{0}); }; AudioClientImpl client = build(transport, encodingCodec(), List.of()); client.speech(new TextToSpeechRequest( - TtsModel.FANAR_AURA_TTS_2, "hi", Voice.HARRY, TtsResponseFormat.WAV, null)); + TtsModel.FANAR_AURA_TTS_2, "hi", Voice.HARRY, TtsResponseFormat.WAV, null, null)); assertEquals(Optional.of("audio/*"), captured.get().headers().firstValue("Accept")); } @@ -669,6 +741,7 @@ void allMethodsRejectNullArgs() { assertThrows(NullPointerException.class, () -> client.deleteVoiceAsync(null)); assertThrows(NullPointerException.class, () -> client.speech(null)); assertThrows(NullPointerException.class, () -> client.speechAsync(null)); + assertThrows(NullPointerException.class, () -> client.speechStream(null)); assertThrows(NullPointerException.class, () -> client.transcribe(null)); assertThrows(NullPointerException.class, () -> client.transcribeAsync(null)); } @@ -715,6 +788,10 @@ private static VoiceResponse empty() { return new VoiceResponse(List.of()); } + private static AvailableVoice publicVoice(String name) { + return new AvailableVoice(name, null, null, null, List.of(), VoiceType.PUBLIC, false); + } + /** Codec used by listVoices tests — decodes whatever is read into the canned response. */ private static FanarJsonCodec cannedListCodec(VoiceResponse canned) { return new FanarJsonCodec() { diff --git a/core/src/test/java/qa/fanar/core/internal/audio/AudioStreamPublisherTest.java b/core/src/test/java/qa/fanar/core/internal/audio/AudioStreamPublisherTest.java new file mode 100644 index 0000000..9c46be5 --- /dev/null +++ b/core/src/test/java/qa/fanar/core/internal/audio/AudioStreamPublisherTest.java @@ -0,0 +1,343 @@ +package qa.fanar.core.internal.audio; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import qa.fanar.core.FanarTransportException; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AudioStreamPublisherTest { + + @Test + void happyPathEmitsChunksAndCompletes() throws Exception { + byte[] payload = new byte[20_000]; // > 2 × 8 KiB chunk size → at least 3 chunks + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) i; + } + + CollectingSubscriber sub = new CollectingSubscriber(Long.MAX_VALUE); + new AudioStreamPublisher(new ByteArrayInputStream(payload)).subscribe(sub); + + sub.completed.get(5, TimeUnit.SECONDS); + assertTrue(sub.chunks.size() >= 3, "expected multiple chunks, got " + sub.chunks.size()); + assertArrayEquals(payload, concat(sub.chunks), + "chunks concatenated in emission order must reproduce the payload"); + assertTrue(sub.completedFlag.get()); + } + + @Test + void boundedDemandIsRespected() throws Exception { + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in = new PipedInputStream(out, 32768); + + CollectingSubscriber sub = new CollectingSubscriber(1); // request exactly one + new AudioStreamPublisher(in).subscribe(sub); + + out.write(new byte[]{1, 2, 3}); + out.flush(); + sub.nextReceived.get(5, TimeUnit.SECONDS); + assertEquals(1, sub.chunks.size()); + + out.write(new byte[]{4, 5}); + out.flush(); + // The producer parks awaiting demand; request one more and it delivers. + sub.subscription.request(1); + sub.secondReceived.get(5, TimeUnit.SECONDS); + assertEquals(2, sub.chunks.size()); + + out.close(); + sub.completed.get(5, TimeUnit.SECONDS); + } + + @Test + void zeroLengthReadsAreSkipped() throws Exception { + // A transport read may legally return 0 bytes; the producer must loop rather than emit + // an empty chunk or complete early. + AtomicBoolean zeroServed = new AtomicBoolean(); + InputStream body = new InputStream() { + private final ByteArrayInputStream data = new ByteArrayInputStream(new byte[]{7, 8}); + @Override + public int read() throws IOException { + return data.read(); + } + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (zeroServed.compareAndSet(false, true)) { + return 0; + } + return data.read(b, off, len); + } + }; + + CollectingSubscriber sub = new CollectingSubscriber(Long.MAX_VALUE); + new AudioStreamPublisher(body).subscribe(sub); + + sub.completed.get(5, TimeUnit.SECONDS); + assertEquals(1, sub.chunks.size()); + assertArrayEquals(new byte[]{7, 8}, sub.chunks.getFirst()); + } + + @Test + void secondSubscriberIsRejected() throws Exception { + AudioStreamPublisher publisher = new AudioStreamPublisher(new ByteArrayInputStream(new byte[0])); + + CollectingSubscriber first = new CollectingSubscriber(Long.MAX_VALUE); + publisher.subscribe(first); + first.completed.get(5, TimeUnit.SECONDS); + + CollectingSubscriber second = new CollectingSubscriber(Long.MAX_VALUE); + publisher.subscribe(second); + + Throwable err = second.errored.get(5, TimeUnit.SECONDS); + assertInstanceOf(IllegalStateException.class, err); + assertTrue(err.getMessage().contains("single subscriber")); + + // NoopSubscription is a no-op — requesting / cancelling must be safe. + second.subscription.request(10); + second.subscription.cancel(); + } + + @Test + void cancelStopsDeliveryAndClosesStream() throws Exception { + AtomicBoolean closed = new AtomicBoolean(); + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream piped = new PipedInputStream(out, 32768); + InputStream body = new InputStream() { + public int read() throws IOException { return piped.read(); } + public int read(byte[] b, int off, int len) throws IOException { return piped.read(b, off, len); } + public void close() throws IOException { closed.set(true); piped.close(); } + }; + + // Cancel synchronously from inside onNext (producer thread) so `cancelled=true` is + // published before the producer re-evaluates the while-loop header — same determinism + // rationale as SseStreamPublisherTest. + CollectingSubscriber sub = new CollectingSubscriber(Long.MAX_VALUE) { + @Override + public void onNext(byte[] item) { + super.onNext(item); + subscription.cancel(); + } + }; + new AudioStreamPublisher(body).subscribe(sub); + + out.write(new byte[]{1}); + out.flush(); + sub.nextReceived.get(5, TimeUnit.SECONDS); + Thread.sleep(100); + + assertEquals(1, sub.chunks.size()); + assertFalse(sub.completedFlag.get(), "onComplete must not fire after cancel"); + assertTrue(closed.get(), "underlying body must be closed on cancel"); + } + + @Test + void ioErrorDuringReadSurfacesAsOnError() throws Exception { + InputStream broken = new InputStream() { + public int read() throws IOException { throw new IOException("boom"); } + }; + CollectingSubscriber sub = new CollectingSubscriber(Long.MAX_VALUE); + new AudioStreamPublisher(broken).subscribe(sub); + + Throwable err = sub.errored.get(5, TimeUnit.SECONDS); + assertInstanceOf(IOException.class, err); + } + + @Test + void requestZeroTerminatesWithIllegalArgument() throws Exception { + CollectingSubscriber sub = new CollectingSubscriber(0); // no initial demand + new AudioStreamPublisher(new ByteArrayInputStream(new byte[0])).subscribe(sub); + + sub.subscription.request(0); + Throwable err = sub.errored.get(5, TimeUnit.SECONDS); + assertInstanceOf(IllegalArgumentException.class, err); + } + + @Test + void requestOverflowSaturatesToMaxValue() throws Exception { + CollectingSubscriber sub = new CollectingSubscriber(0); + new AudioStreamPublisher(new ByteArrayInputStream(new byte[]{1, 2, 3})).subscribe(sub); + + // Long.MAX_VALUE twice — must not roll negative. + sub.subscription.request(Long.MAX_VALUE); + sub.subscription.request(Long.MAX_VALUE); + + sub.completed.get(5, TimeUnit.SECONDS); + assertArrayEquals(new byte[]{1, 2, 3}, concat(sub.chunks)); + } + + @Test + void nullArgsAreRejected() { + assertThrows(NullPointerException.class, () -> new AudioStreamPublisher(null)); + + AudioStreamPublisher publisher = new AudioStreamPublisher(new ByteArrayInputStream(new byte[0])); + assertThrows(NullPointerException.class, () -> publisher.subscribe(null)); + } + + @Test + void cancelDuringAwaitDemandExitsWithoutDelivery() throws Exception { + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in = new PipedInputStream(out, 32768); + + CollectingSubscriber sub = new CollectingSubscriber(1); // only allow one chunk + new AudioStreamPublisher(in).subscribe(sub); + + out.write(new byte[]{1}); + out.flush(); + sub.nextReceived.get(5, TimeUnit.SECONDS); + + // Second chunk arrives while demand is exhausted; the producer parks in awaitDemand. + out.write(new byte[]{2}); + out.flush(); + Thread.sleep(100); + + sub.subscription.cancel(); + Thread.sleep(100); + + assertEquals(1, sub.chunks.size(), "second chunk must not be delivered after cancel"); + assertFalse(sub.completedFlag.get()); + } + + @Test + void interruptDuringAwaitDemandSurfacesAsError() throws Exception { + CollectingSubscriber sub = new CollectingSubscriber(1) { + @Override + public void onNext(byte[] item) { + super.onNext(item); + // Self-interrupt the producer (we are executing on it) so the next awaitDemand + // wait() throws immediately instead of blocking forever. + Thread.currentThread().interrupt(); + } + }; + byte[] payload = new byte[10_000]; // two chunks; demand of 1 forces awaitDemand + new AudioStreamPublisher(new ByteArrayInputStream(payload)).subscribe(sub); + + Throwable err = sub.errored.get(5, TimeUnit.SECONDS); + assertInstanceOf(FanarTransportException.class, err); + assertInstanceOf(InterruptedException.class, err.getCause()); + assertTrue(err.getMessage().contains("interrupted")); + assertEquals(1, sub.chunks.size()); + } + + @Test + void ioErrorAfterCancelIsSwallowed() throws Exception { + // Exercises the catch (Throwable) branch where cancel has already been called: the read + // throws because we just closed the body; the producer must not surface that to the + // subscriber (they asked to stop). + CountDownLatch inRead = new CountDownLatch(1); + AtomicBoolean closed = new AtomicBoolean(); + InputStream body = new InputStream() { + @Override + public int read() throws IOException { + inRead.countDown(); + while (!closed.get()) { + try { Thread.sleep(10); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + throw new IOException("read-after-close (expected — must be swallowed)"); + } + @Override + public int read(byte[] b, int off, int len) throws IOException { + int c = read(); + return c; + } + @Override + public void close() { closed.set(true); } + }; + + CollectingSubscriber sub = new CollectingSubscriber(Long.MAX_VALUE); + new AudioStreamPublisher(body).subscribe(sub); + assertTrue(inRead.await(5, TimeUnit.SECONDS)); + + sub.subscription.cancel(); + Thread.sleep(100); + + assertFalse(sub.errored.isDone(), "post-cancel IOException must not surface to the subscriber"); + assertFalse(sub.completedFlag.get(), "no terminal signal after cancel"); + } + + @Test + void cancelSwallowsCloseIoError() throws Exception { + PipedOutputStream out = new PipedOutputStream(); + PipedInputStream piped = new PipedInputStream(out, 32768); + AtomicBoolean closeCalled = new AtomicBoolean(); + InputStream body = new InputStream() { + public int read() throws IOException { return piped.read(); } + public int read(byte[] b, int off, int len) throws IOException { return piped.read(b, off, len); } + public void close() throws IOException { + closeCalled.set(true); + piped.close(); + throw new IOException("close failure (expected — must be swallowed)"); + } + }; + + CollectingSubscriber sub = new CollectingSubscriber(Long.MAX_VALUE); + new AudioStreamPublisher(body).subscribe(sub); + Thread.sleep(50); + + // Must not propagate the close IOException out of cancel(). + sub.subscription.cancel(); + Thread.sleep(50); + + assertTrue(closeCalled.get()); + assertFalse(sub.errored.isDone(), "close-time IOException must be swallowed silently"); + } + + // --- helpers + + private static byte[] concat(List chunks) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + chunks.forEach(c -> buf.writeBytes(c)); + return buf.toByteArray(); + } + + private static class CollectingSubscriber implements Flow.Subscriber { + final List chunks = new CopyOnWriteArrayList<>(); + final CompletableFuture completed = new CompletableFuture<>(); + final CompletableFuture errored = new CompletableFuture<>(); + final CompletableFuture nextReceived = new CompletableFuture<>(); + final CompletableFuture secondReceived = new CompletableFuture<>(); + final AtomicBoolean completedFlag = new AtomicBoolean(); + final long initialDemand; + + volatile Flow.Subscription subscription; + + CollectingSubscriber(long initialDemand) { + this.initialDemand = initialDemand; + } + + @Override + public void onSubscribe(Flow.Subscription s) { + this.subscription = s; + if (initialDemand > 0) s.request(initialDemand); + } + @Override + public void onNext(byte[] item) { + chunks.add(item); + if (!nextReceived.isDone()) nextReceived.complete(null); + else if (!secondReceived.isDone()) secondReceived.complete(null); + } + @Override + public void onError(Throwable throwable) { errored.complete(throwable); completed.complete(null); } + @Override + public void onComplete() { completedFlag.set(true); completed.complete(null); } + } +} diff --git a/core/src/test/java/qa/fanar/core/internal/images/ImagesClientImplTest.java b/core/src/test/java/qa/fanar/core/internal/images/ImagesClientImplTest.java index 5bd1a1e..ed88e09 100644 --- a/core/src/test/java/qa/fanar/core/internal/images/ImagesClientImplTest.java +++ b/core/src/test/java/qa/fanar/core/internal/images/ImagesClientImplTest.java @@ -297,7 +297,7 @@ private static ImageGenerationRequest request() { private static ImageGenerationResponse response() { return new ImageGenerationResponse( - "req_1", 1_700_000_000L, List.of(new ImageGenerationItem("aGVsbG8="))); + "req_1", 1_700_000_000L, List.of(new ImageGenerationItem("aGVsbG8=", true, "a refined prompt"))); } private static FanarJsonCodec cannedCodec(ImageGenerationResponse canned) { diff --git a/core/src/test/java/qa/fanar/core/internal/transport/ErrorEnvelopeTest.java b/core/src/test/java/qa/fanar/core/internal/transport/ErrorEnvelopeTest.java new file mode 100644 index 0000000..aa93c08 --- /dev/null +++ b/core/src/test/java/qa/fanar/core/internal/transport/ErrorEnvelopeTest.java @@ -0,0 +1,115 @@ +package qa.fanar.core.internal.transport; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ErrorEnvelopeTest { + + // --- well-formed envelopes + + @Test + void parsesCodeMessageAndIgnoresStatus() { + ErrorEnvelope e = ErrorEnvelope.tryParse( + "{\"error\":{\"code\":\"conflict\",\"message\":\"duplicate voice\",\"status\":409}}"); + assertEquals("conflict", e.code()); + assertEquals("duplicate voice", e.message()); + } + + @Test + void messageIsOptional() { + ErrorEnvelope e = ErrorEnvelope.tryParse("{\"error\":{\"code\":\"timeout\"}}"); + assertEquals("timeout", e.code()); + assertNull(e.message()); + } + + @Test + void toleratesArbitraryWhitespace() { + ErrorEnvelope e = ErrorEnvelope.tryParse( + " {\n\t\"error\" : { \"code\" : \"overloaded\" , \"message\" : \"busy\" }\r\n} "); + assertEquals("overloaded", e.code()); + assertEquals("busy", e.message()); + } + + @Test + void skipsForeignKeysOfEveryJsonType() { + // string, number (with sign/exponent), object, array, booleans, null — before and after "error". + ErrorEnvelope e = ErrorEnvelope.tryParse(""" + {"a":"x","b":-1.5e+10,"c":{"n":{"deep":[1,2]}},"d":[{"k":"v"},[3],"s"], + "error":{"code":"conflict","status":409,"extra":{"why":"dup"}}, + "e":true,"f":false,"g":null,"h":2E8}"""); + assertEquals("conflict", e.code()); + assertNull(e.message()); + } + + @Test + void skipsStringsContainingBracketsAndEscapedQuotes() { + ErrorEnvelope e = ErrorEnvelope.tryParse( + "{\"noise\":{\"s\":\"}]\\\"{[\"},\"error\":{\"code\":\"timeout\"}}"); + assertEquals("timeout", e.code()); + } + + @Test + void skipsEmptyContainers() { + ErrorEnvelope e = ErrorEnvelope.tryParse( + "{\"a\":{},\"b\":[],\"error\":{\"code\":\"conflict\"}}"); + assertEquals("conflict", e.code()); + } + + @Test + void decodesEveryEscapeSequence() { + ErrorEnvelope e = ErrorEnvelope.tryParse( + "{\"error\":{\"code\":\"conflict\",\"message\":\"\\\" \\\\ \\/ \\b \\f \\n \\r \\t \\u0041\"}}"); + assertEquals("\" \\ / \b \f \n \r \t A", e.message()); + } + + @Test + void lastDuplicateKeyWins() { + ErrorEnvelope e = ErrorEnvelope.tryParse( + "{\"error\":{\"code\":\"timeout\",\"code\":\"conflict\"}}"); + assertEquals("conflict", e.code()); + } + + @Test + void wireValueWithSpaceSurvives() { + // ErrorCode.NOT_FOUND's wire value is literally "Not found". + assertEquals("Not found", ErrorEnvelope.tryParse("{\"error\":{\"code\":\"Not found\"}}").code()); + } + + // --- shape deviations → null (mapper falls back to status routing) + + @ParameterizedTest(name = "[{index}] {0}") + @NullSource + @ValueSource(strings = { + "", // blank + " \n ", // blank + "teapot", // not JSON + "[]", // top-level array + "42", // top-level number + "\"error\"", // top-level string + "{}", // no "error" member + "{\"error\":{}}", // no code + "{\"error\":{\"message\":\"m\"}}", // no code + "{\"error\":\"nope\"}", // error not an object + "{\"error\":{\"code\":123}}", // code not a string + "{\"error\":{\"code\":\"x\"", // truncated before closes + "{\"error\":{\"code\":\"x\"}}trailing", // trailing garbage + "{\"error\":{\"code\":\"x\"};", // wrong member separator + "{\"error\" {\"code\":\"x\"}}", // missing colon + "{\"error\":{\"code\":\"unterminated", // unterminated string + "{\"error\":{\"code\":\"\\q\"}}", // unknown escape + "{\"error\":{\"code\":\"\\uZZZZ\"}}", // non-hex unicode escape + "{\"error\":{\"code\":\"\\u12", // unicode escape hits end of input + "{\"a\":tru,\"error\":{\"code\":\"x\"}}", // bad literal + "{\"a\":?,\"error\":{\"code\":\"x\"}}", // no value at all + "{\"a\":123", // number runs to end of input + "{\"a\":[1,2,\"error\":{\"code\":\"x\"}}", // unbalanced container runs to end of input + }) + void malformedOrForeignShapesYieldNull(String body) { + assertNull(ErrorEnvelope.tryParse(body)); + } +} diff --git a/core/src/test/java/qa/fanar/core/internal/transport/ExceptionMapperTest.java b/core/src/test/java/qa/fanar/core/internal/transport/ExceptionMapperTest.java index 52707a2..1c75bac 100644 --- a/core/src/test/java/qa/fanar/core/internal/transport/ExceptionMapperTest.java +++ b/core/src/test/java/qa/fanar/core/internal/transport/ExceptionMapperTest.java @@ -17,9 +17,13 @@ import javax.net.ssl.SSLSession; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import qa.fanar.core.FanarAuthenticationException; import qa.fanar.core.FanarAuthorizationException; +import qa.fanar.core.FanarClientClosedRequestException; import qa.fanar.core.FanarConflictException; import qa.fanar.core.FanarContentFilterException; import qa.fanar.core.FanarException; @@ -27,10 +31,12 @@ import qa.fanar.core.FanarInternalServerException; import qa.fanar.core.FanarNotFoundException; import qa.fanar.core.FanarOverloadedException; +import qa.fanar.core.FanarQuotaExceededException; import qa.fanar.core.FanarRateLimitException; import qa.fanar.core.FanarTimeoutException; import qa.fanar.core.FanarTooLargeException; import qa.fanar.core.FanarUnprocessableException; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -113,11 +119,98 @@ void status503MapsToOverloaded() { assertInstanceOf(FanarOverloadedException.class, ExceptionMapper.map(response(503, "", Map.of()))); } + @Test + void status499MapsToClientClosedRequest() { + assertInstanceOf(FanarClientClosedRequestException.class, ExceptionMapper.map(response(499, "", Map.of()))); + } + @Test void status504MapsToTimeout() { assertInstanceOf(FanarTimeoutException.class, ExceptionMapper.map(response(504, "", Map.of()))); } + // --- envelope-code routing (the typed code is authoritative; status is the fallback) + + @ParameterizedTest(name = "{0}") + @MethodSource("envelopeCodes") + void envelopeCodeDecidesTheSubtype(String wireCode, Class expected) { + // Status deliberately unknown (418) to prove the typed code wins over status routing. + String body = "{\"error\":{\"code\":\"" + wireCode + "\",\"message\":\"m\",\"status\":418}}"; + FanarException ex = ExceptionMapper.map(response(418, body, Map.of())); + assertInstanceOf(expected, ex); + assertEquals("m", ex.getMessage()); + } + + static Stream envelopeCodes() { + return Stream.of( + Arguments.of("content_filter", FanarContentFilterException.class), + Arguments.of("invalid_authentication", FanarAuthenticationException.class), + Arguments.of("invalid_authorization", FanarAuthorizationException.class), + Arguments.of("rate_limit_reached", FanarRateLimitException.class), + Arguments.of("exceeded_quota", FanarQuotaExceededException.class), + Arguments.of("internal_server_error", FanarInternalServerException.class), + Arguments.of("overloaded", FanarOverloadedException.class), + Arguments.of("timeout", FanarTimeoutException.class), + Arguments.of("too_large", FanarTooLargeException.class), + Arguments.of("unprocessable", FanarUnprocessableException.class), + Arguments.of("conflict", FanarConflictException.class), + Arguments.of("Not found", FanarNotFoundException.class), + Arguments.of("no_longer_supported", FanarGoneException.class), + Arguments.of("client_closed_request", FanarClientClosedRequestException.class)); + } + + @Test + void quotaEnvelopeOn429IsNotRateLimit() { + // Both wire as HTTP 429; only the typed code can distinguish permanent quota exhaustion + // from transient throttling. Pure status routing used to collapse both to rate-limit. + String body = "{\"error\":{\"code\":\"exceeded_quota\",\"message\":\"quota exhausted\",\"status\":429}}"; + FanarException ex = ExceptionMapper.map(response(429, body, Map.of())); + assertInstanceOf(FanarQuotaExceededException.class, ex); + assertEquals("quota exhausted", ex.getMessage()); + } + + @Test + void nonFilterEnvelopeOn400IsNotContentFilter() { + String body = "{\"error\":{\"code\":\"unprocessable\",\"message\":\"bad shape\",\"status\":400}}"; + assertInstanceOf(FanarUnprocessableException.class, ExceptionMapper.map(response(400, body, Map.of()))); + } + + @Test + void rateLimitEnvelopeStillHonorsRetryAfter() { + String body = "{\"error\":{\"code\":\"rate_limit_reached\",\"message\":\"slow down\",\"status\":429}}"; + FanarException ex = ExceptionMapper.map(response(429, body, Map.of("Retry-After", List.of("7")))); + assertEquals(Duration.ofSeconds(7), ((FanarRateLimitException) ex).retryAfter()); + } + + @Test + void unknownEnvelopeCodeFallsBackToStatusRoutingButKeepsTheMessage() { + String body = "{\"error\":{\"code\":\"flux_capacitor\",\"message\":\"m\",\"status\":503}}"; + FanarException ex = ExceptionMapper.map(response(503, body, Map.of())); + assertInstanceOf(FanarOverloadedException.class, ex); + assertEquals("m", ex.getMessage()); + } + + @Test + void malformedEnvelopeFallsBackToStatusRoutingWithRawBody() { + FanarException ex = ExceptionMapper.map(response(409, "{\"error\":{\"code\":", Map.of())); + assertInstanceOf(FanarConflictException.class, ex); + assertEquals("{\"error\":{\"code\":", ex.getMessage()); + } + + @Test + void envelopeWithoutMessageFallsBackToRawBody() { + String body = "{\"error\":{\"code\":\"conflict\",\"status\":409}}"; + FanarException ex = ExceptionMapper.map(response(409, body, Map.of())); + assertInstanceOf(FanarConflictException.class, ex); + assertEquals(body, ex.getMessage()); + } + + @Test + void envelopeWithBlankMessageFallsBackToRawBody() { + String body = "{\"error\":{\"code\":\"conflict\",\"message\":\"\",\"status\":409}}"; + assertEquals(body, ExceptionMapper.map(response(409, body, Map.of())).getMessage()); + } + @Test void unknownStatusMapsToInternalServer() { FanarException ex = ExceptionMapper.map(response(418, "teapot", Map.of())); @@ -142,6 +235,7 @@ void blankBodyCoversEveryKnownStatusReason() { assertEquals("Request entity too large", ExceptionMapper.map(response(413, "", Map.of())).getMessage()); assertEquals("Unprocessable entity", ExceptionMapper.map(response(422, "", Map.of())).getMessage()); assertEquals("Rate limit reached", ExceptionMapper.map(response(429, "", Map.of())).getMessage()); + assertEquals("Client closed request", ExceptionMapper.map(response(499, "", Map.of())).getMessage()); assertEquals("Internal server error", ExceptionMapper.map(response(500, "", Map.of())).getMessage()); assertEquals("Service overloaded", ExceptionMapper.map(response(503, "", Map.of())).getMessage()); assertEquals("Upstream timeout", ExceptionMapper.map(response(504, "", Map.of())).getMessage()); diff --git a/core/src/test/java/qa/fanar/core/internal/transport/StreamFlagTest.java b/core/src/test/java/qa/fanar/core/internal/transport/StreamFlagTest.java new file mode 100644 index 0000000..139053b --- /dev/null +++ b/core/src/test/java/qa/fanar/core/internal/transport/StreamFlagTest.java @@ -0,0 +1,40 @@ +package qa.fanar.core.internal.transport; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import qa.fanar.core.FanarTransportException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StreamFlagTest { + + @Test + void injectsAsFirstPropertyWithComma() { + assertEquals("{\"stream\":true,\"model\":\"Fanar\"}", + inject("{\"model\":\"Fanar\"}")); + } + + @Test + void injectsIntoEmptyObjectWithoutComma() { + assertEquals("{\"stream\":true}", inject("{}")); + } + + @Test + void rejectsNonObjectBody() { + assertThrows(FanarTransportException.class, () -> inject("[1,2]")); + } + + @Test + void rejectsTooShortBody() { + assertThrows(FanarTransportException.class, () -> inject("")); + assertThrows(FanarTransportException.class, () -> inject("{")); + } + + private static String inject(String json) { + return new String( + StreamFlag.inject(json.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + } +} diff --git a/docs/API_SKETCH.md b/docs/API_SKETCH.md index 5bca6ad..bee32bc 100644 --- a/docs/API_SKETCH.md +++ b/docs/API_SKETCH.md @@ -216,23 +216,52 @@ for (Reference ref : message.references()) { The `references()` list is Fanar-exclusive; no OpenAI-compatible client surfaces it. +### Persona and madhab (2026-08 spec) + +```java +// Custom assistant persona — Fanar-Sadiq only. +ChatRequest withPersona = ChatRequest.builder() + .model(ChatModel.FANAR_SADIQ) + .addMessage(UserMessage.of("What are the Islamic values?")) + .persona("You are a warm, patient teacher who explains concepts simply for young students.") + .build(); + +// Madhab-aware retrieval — Fanar-Sadiq-2 (extra authorization required). +ChatRequest withMadhab = ChatRequest.builder() + .model(ChatModel.FANAR_SADIQ_2) + .addMessage(UserMessage.of("What are the conditions for Zakat on gold?")) + .madhab(List.of(Madhab.HANAFI)) + .build(); +``` + --- ## 6. Other domains ```java -// Text-to-speech (including Quranic TTS with validated reciters) +// Text-to-speech (including Quranic TTS with validated reciters and emotional synthesis) client.audio().speech(TextToSpeechRequest.builder() .model(TtsModel.FANAR_AURA_TTS_2) - .voice(Voice.AMELIA) + .voice(Voice.RADWA) // Radwa + Abdulrahman support with_emotion .input("Hello from Fanar") + .withEmotion(true) .build()); +// Streamed TTS — chunks arrive as the server generates them (ADR-023) +Flow.Publisher audio = client.audio().speechStream( + TextToSpeechRequest.of(TtsModel.FANAR_AURA_TTS_2, "Hello from Fanar", Voice.HAMAD)); + +// Voice catalogue — rich objects; always includes the built-in public voices +client.audio().listVoices().voices().forEach(v -> + System.out.printf("%s (%s, %s) emotion=%b%n", v.name(), v.gender(), v.accent(), v.emotion())); + // Speech-to-text client.audio().transcribe(TranscriptionRequest.of(audioFile, SttModel.FANAR_AURA_STT_1)); -// Image generation -client.images().generate(ImageGenerationRequest.of(ImageModel.FANAR_ORYX_IG_2, "A futuristic Doha skyline")); +// Image generation — the server revises prompts by default (revise=true); each item reports +// revised() + revisedPrompt(). Pass revise=false to keep the prompt verbatim. +client.images().generate(new ImageGenerationRequest( + ImageModel.FANAR_ORYX_IG_2, "A futuristic Doha skyline", false)); // Translation client.translations().send(TranslationRequest.of(TranslationModel.FANAR_SHAHEEN_MT_1, @@ -402,7 +431,7 @@ Any `Interceptor` or `ObservabilityPlugin` beans on the application context get org.springframework.ai spring-ai-client-chat - + ``` @@ -433,6 +462,19 @@ String chat(@PathVariable("conversationId") String id, @RequestBody Prompt p) { Memory + RAG advisors + prompt templates + structured-output converters all attach via Spring AI's standard machinery — we provide only the model SPIs. +Fanar-only knobs travel through the vendor options classes (ADR-024): + +```java +chatClient.prompt() + .user("What are the conditions for Zakat on gold?") + .options(FanarChatOptions.builder() + .model("Fanar-Sadiq-2") + .madhab(List.of(Madhab.HANAFI)) + .build()) + .call() + .content(); +``` + --- ## What this document does **not** show — and why diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 12afd08..0e21db9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -> OpenAPI 3.1.0 — 12 endpoints, 14 models +> OpenAPI 3.1.0 — 12 endpoints, 15 models --- @@ -34,6 +34,7 @@ | `Fanar-C-1-8.7B` | 50/min | Chat (thinking v1) | | `Fanar-C-2-27B` | 50/min | Chat (thinking v2) | | `Fanar-Sadiq` | 50/min | Islamic RAG | +| `Fanar-Sadiq-2` | 50/min | Islamic RAG (madhab-aware) | | `Fanar-Oryx-IVU-2` | 20/day | Vision | | `Fanar-Aura-TTS-2` | 20/day | TTS | | `Fanar-Sadiq-TTS-1` | 20/day | Quranic TTS | @@ -187,6 +188,10 @@ The caller's `Flow.Subscriber` consumes events as they arrive and p `StreamEvent` hierarchy (ADR-005). Interceptors apply to the initial connection handshake only; mid-stream events bypass them. +Streamed TTS (`client.audio().speechStream(request)`) follows the same shape with the SSE stages removed: the +`stream:true` flag is spliced into the encoded body, and the raw response `InputStream` feeds a +`Flow.Publisher` that emits opaque audio chunks (ADR-023). + ### Seams (extension points) | Seam | SPI / config slot | Default if not set | @@ -216,7 +221,8 @@ FanarException (sealed, unchecked) │ ├── FanarConflictException │ ├── FanarTooLargeException │ ├── FanarUnprocessableException -│ └── FanarGoneException +│ ├── FanarGoneException +│ └── FanarClientClosedRequestException └── FanarServerException (sealed 5xx) ├── FanarRateLimitException ├── FanarOverloadedException @@ -224,7 +230,9 @@ FanarException (sealed, unchecked) └── FanarInternalServerException ``` -One subtype per Fanar `ErrorCode` plus `FanarTransportException` for JDK-transport failures. See ADR-006. +One subtype per Fanar `ErrorCode` plus `FanarTransportException` for JDK-transport failures. The +mapper routes by the typed `code` in the error envelope first and falls back to HTTP status when the +body isn't a well-formed envelope. See ADR-006. --- @@ -238,22 +246,23 @@ zone (ADR-018). |---|---|---| | Public entry point | `qa.fanar.core.FanarClient` (+ nested `Builder`) | **implemented** — wires the transport, bearer-token interceptor, and `ChatClientImpl` | | Chat domain facade | `qa.fanar.core.chat.ChatClient` | **implemented** (interface); `qa.fanar.core.internal.chat.ChatClientImpl` runs `send` / `sendAsync` / `stream` end-to-end | -| Exception hierarchy root | `qa.fanar.core.FanarException` | **implemented** (sealed, 13 subtypes) | +| Exception hierarchy root | `qa.fanar.core.FanarException` | **implemented** (sealed, 14 subtypes) | | Error-code enum | `qa.fanar.core.ErrorCode` | **implemented** | | Content-filter-type record | `qa.fanar.core.ContentFilterType` | **implemented** — open value class (record) with constants + `of(String)` factory | | Domain DTOs — chat messages | `qa.fanar.core.chat.Message` + variants + content parts + `ToolCall` | **implemented** | -| Domain DTOs — chat value classes | `qa.fanar.core.chat.{ChatModel, Source, ImageDetail, FinishReason, BookName}` | **implemented** — open value-class records with constants + permissive `of(String)`; `BookName` carries 572 inline `KNOWN` entries from `BookNamesEnum` | -| `ChatRequest` (+ `Builder`) | `qa.fanar.core.chat.ChatRequest` | **implemented** (31-component record, fluent builder) | +| Domain DTOs — chat value classes | `qa.fanar.core.chat.{ChatModel, Source, ImageDetail, FinishReason, BookName, Madhab}` | **implemented** — open value-class records with constants + permissive `of(String)`; `BookName` carries 572 inline `KNOWN` entries from `BookNamesEnum` | +| `ChatRequest` (+ `Builder`) | `qa.fanar.core.chat.ChatRequest` | **implemented** (33-component record, fluent builder) | | `ChatResponse` + response types | `qa.fanar.core.chat.{ChatResponse, ChatChoice, ChatMessage, Reference, FinishReason, ResponseContent, TextContent, ImageContent, AudioContent, CompletionUsage, CompletionTokensDetails, PromptTokensDetails, ChoiceLogprobs, TokenLogprob, TopLogprob}` | **implemented** | -| Other domain DTOs + clients | `qa.fanar.core.` | **implemented** — per-domain client interface, open value-class records, and DTOs; each surfaced via `client.audio()` / `.images()` / `.translations()` / `.poems()` / `.moderations()` / `.tokens()` / `.models()`. Audio additionally exposes a sealed `SpeechToTextResponse` with text / srt / json variants. | +| Other domain DTOs + clients | `qa.fanar.core.` | **implemented** — per-domain client interface, open value-class records, and DTOs; each surfaced via `client.audio()` / `.images()` / `.translations()` / `.poems()` / `.moderations()` / `.tokens()` / `.models()`. Audio additionally exposes a sealed `SpeechToTextResponse` with text / srt / json variants, the rich voice-catalogue records `AvailableVoice` / `VoiceType`, and streamed TTS via `speechStream(...)` → `Flow.Publisher` (ADR-023). | | Sealed `StreamEvent` hierarchy | `qa.fanar.core.chat.{StreamEvent, TokenChunk, ToolCallChunk, ToolResultChunk, ProgressChunk, DoneChunk, ErrorChunk, ChoiceToken, ChoiceToolCall, ChoiceToolResult, ChoiceFinal, ChoiceError, ProgressMessage, FunctionData, ToolCallData, ToolResultData}` | **implemented** | | Extension SPIs | `qa.fanar.core.spi` | **implemented** (FanarJsonCodec, Interceptor+Chain, ObservabilityPlugin, ObservationHandle, FanarObservationAttributes) | | Default no-op observability | `qa.fanar.core.internal.observability` | **implemented** (NoopObservabilityPlugin, NoopObservationHandle) | | Composite observability | `qa.fanar.core.internal.observability.CompositeObservabilityPlugin` | **implemented** — produced by `ObservabilityPlugin.compose(...)`; fans out `start` / `attribute` / `event` / `error` / `child` to N children, merges `propagationHeaders` (last-write-wins on key collision) | | Retry policy (public) | `qa.fanar.core.RetryPolicy` + `qa.fanar.core.JitterStrategy` | **implemented** (record + enum; retry loop still to come) | -| HTTP transport | `qa.fanar.core.internal.transport` (`HttpTransport`, `DefaultHttpTransport`, `InterceptorChainImpl`, `ExceptionMapper`) | **implemented** | +| HTTP transport | `qa.fanar.core.internal.transport` (`HttpTransport`, `DefaultHttpTransport`, `InterceptorChainImpl`, `ExceptionMapper`, `ErrorEnvelope`) | **implemented** | | Bearer-token interceptor impl | `qa.fanar.core.internal.transport.BearerTokenInterceptor` | **implemented** — per-call `Supplier` for token rotation | | SSE parser | `qa.fanar.core.internal.sse` (`SseFrameAssembler`, `StreamEventDecoder`, `SseStreamPublisher`) | **implemented** — line-oriented accumulator, shape-routed decode, single-subscriber `Flow.Publisher` on a virtual thread | +| Audio stream publisher | `qa.fanar.core.internal.audio.AudioStreamPublisher` | **implemented** — `SseStreamPublisher`'s structural twin minus frame assembly; emits opaque `byte[]` chunks for streamed TTS (ADR-023); `stream:true` spliced via the shared `internal.transport.StreamFlag` helper | | Retry interceptor impl | `qa.fanar.core.internal.retry.RetryInterceptor` | **implemented** — exponential back-off with configurable jitter, `Retry-After` honouring on 429, `retry_attempt` observation events, injectable `Sleeper`+`RandomGenerator` | | Jackson 2 codec | `qa.fanar.json.jackson2.Jackson2FanarJsonCodec` | **implemented** — snake-case naming, NON_NULL inclusion, six flattening deserializers, generic wire-value module (records or enums via `wireValue()` / `of(String)`), `ServiceLoader` descriptor, reachability metadata | | Jackson 3 codec | `qa.fanar.json.jackson3.Jackson3FanarJsonCodec` | **implemented** — snake-case naming, NON_NULL inclusion, six flattening deserializers, generic wire-value module (records or enums via `wireValue()` / `of(String)`), `ServiceLoader` descriptor, reachability metadata | @@ -263,11 +272,11 @@ zone (ADR-018). | Wire logging interceptor | `qa.fanar.interceptor.logging.WireLoggingInterceptor` | **implemented** — OkHttp-style level ladder (`NONE` / `BASIC` / `HEADERS` / `BODY`), SLF4J sink at `fanar.wire`, configurable header redaction (default `Authorization`), body byte cap, streaming-aware (skips `text/event-stream` bodies); `provided`-scope SLF4J | | Spring Boot 4 auto-configuration | `qa.fanar.spring.boot.v4.FanarAutoConfiguration` + `FanarProperties` | **implemented** — typed `fanar.*` `@ConfigurationProperties` record (api-key, base-url, timeouts, retry, wire-logging level), `FanarClient` bean with auto-wired `Interceptor` + `ObservabilityPlugin` via `ObjectProvider`, default Jackson 3 codec | | Spring Boot 4 health indicator | `qa.fanar.spring.boot.v4.FanarHealthIndicator` + `FanarHealthAutoConfiguration` | **implemented** — `AbstractHealthIndicator` calling `models().list()`; activates only when `spring-boot-health` is on the classpath (`provided + optional`); UP carries model count + request id, DOWN carries error class + HTTP status; gated by `management.health.fanar.enabled` | -| Spring AI 2.0 chat adapter | `qa.fanar.spring.ai.FanarChatModel` | **implemented** — `ChatModel` + `StreamingChatModel`; maps `Prompt` / `ChatOptions` onto `ChatRequest`, bridges `Flow.Publisher` to `Flux`, drops TOOL messages and `ProgressChunk` / `ToolCallChunk` / `ToolResultChunk` (Fanar's tool calls are server-internal Sadiq retriever telemetry, not user tools) | +| Spring AI 2.0 chat adapter | `qa.fanar.spring.ai.FanarChatModel` | **implemented** — `ChatModel` + `StreamingChatModel`; maps `Prompt` / `ChatOptions` onto `ChatRequest` (pass `FanarChatOptions` for the Fanar-only knobs — persona, madhab, thinking, RAG scoping, vLLM sampling; ADR-024), bridges `Flow.Publisher` to `Flux`, drops TOOL messages and `ProgressChunk` / `ToolCallChunk` / `ToolResultChunk` (Fanar's tool calls are server-internal Sadiq retriever telemetry, not user tools) | | Spring AI 2.0 image adapter | `qa.fanar.spring.ai.FanarImageModel` | **implemented** — `ImageModel`; maps `ImagePrompt` onto `ImageGenerationRequest`, joins multi-message prompts with newlines, returns `b64Json` | -| Spring AI 2.0 audio adapters | `qa.fanar.spring.ai.FanarTextToSpeechModel`, `FanarTranscriptionModel` | **implemented** — TTS satisfies `StreamingTextToSpeechModel` by wrapping the one-shot result as a single-element `Flux`; STT reads bytes from Spring's `Resource`, infers `Content-Type` from filename extension, always requests text format | +| Spring AI 2.0 audio adapters | `qa.fanar.spring.ai.FanarTextToSpeechModel`, `FanarTranscriptionModel` | **implemented** — TTS satisfies `StreamingTextToSpeechModel` by wrapping the one-shot result as a single-element `Flux`; STT reads bytes from Spring's `Resource`, infers `Content-Type` from filename extension, always requests text format; TTS streams for real via `speechStream` and honours `FanarTextToSpeechOptions` (`withEmotion`, `quranReciter`) | | Spring AI 2.0 auto-configuration | `qa.fanar.spring.ai.FanarSpringAiAutoConfiguration` | **implemented** — registers all four model beans `@ConditionalOnMissingBean` so users override per slot; activates after `FanarAutoConfiguration` | -| Reachability metadata | `META-INF/native-image/qa.fanar//` | **shipped** — `fanar-core` carries reflect-config + resource-config metadata for the 38 records the JSON codec touches; both JSON adapters carry adapter-specific metadata; obs / interceptor modules don't need any (no reflection) | +| Reachability metadata | `META-INF/native-image/qa.fanar//` | **shipped** — `fanar-core` carries reflect-config + resource-config metadata for the 32 domain records the JSON codec touches (plus 6 codec helper types); both JSON adapters carry adapter-specific metadata; obs / interceptor modules don't need any (no reflection) | --- diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index d623712..2ca8cfb 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -25,11 +25,11 @@ The core stays **universal**: no hard dependency on any framework, no JSON-libra | Multiple completions | ✅ | `n > 1` returns multiple choices | | Streaming | ✅ | SSE with a typed discriminated union: token · tool-call · tool-result · progress ⭐ · done · error | | Tokenization | ✅ | `POST /v1/tokens` — token count and `max_request_tokens` per model | -| Retrieval-Augmented Generation | ✅ ⭐ | Native via `Fanar-Sadiq` — Islamic-only, with authenticated source references (details below) | +| Retrieval-Augmented Generation | ✅ ⭐ | Native via `Fanar-Sadiq` / `Fanar-Sadiq-2` — Islamic-only, with authenticated source references; Sadiq-2 adds madhab-aware filtering (details below) | | Moderation | ✅ ⭐ | `POST /v1/moderations` — returns a safety score **and** a cultural-awareness score | | Thinking / reasoning | 🟡 ⭐ | Two coexisting protocols (flag + first-class message roles) + `reasoning_tokens` accounted in usage | | Tool calls (client-declared) | 🟡 | The stream emits tool-call and tool-result events, but the request has no `tools` / `tool_choice` parameter — tool invocation is server-initiated only | -| Error model | ✅ | Typed `ErrorCode` enum aligned with HTTP status (content-filter, rate-limit, exceeded-quota, no-longer-supported, …) | +| Error model | ✅ | Typed `ErrorCode` enum, routed from the error envelope's `code` with HTTP-status fallback (content-filter, rate-limit, exceeded-quota, no-longer-supported, client-closed-request, …) | | Structured output (JSON schema) | ❌ | No `response_format` / `json_schema` parameter | | Seed / reproducibility | ❌ | No `seed` parameter | | Embeddings | ❌ | No `/v1/embeddings` endpoint — hard gap | @@ -43,11 +43,11 @@ The core stays **universal**: no hard dependency on any framework, no JSON-libra | Text | ✅ | ✅ | `text` content parts in chat messages | | Image (vision) | ✅ | — | `image_url` user-content part — Arabic-calligraphy-aware ⭐ | | Video | ✅ | — | `video_url` user-content part — first-class type ⭐ | -| Image generation | — | ✅ | `POST /v1/images/generations` — base64 payload | -| Text-to-Speech | — | ✅ | `POST /v1/audio/speech` — includes Quranic TTS with validated reciters ⭐ | +| Image generation | — | ✅ | `POST /v1/images/generations` — base64 payload; automatic prompt revision (`revise`, default on) with the revised prompt echoed back ⭐ | +| Text-to-Speech | — | ✅ | `POST /v1/audio/speech` — buffered or chunk-streamed (`stream`), emotional synthesis (`with_emotion`) on capable voices ⭐, Quranic TTS with validated reciters ⭐ | | Speech-to-Text | ✅ | — | `POST /v1/audio/transcriptions` — short + long-form, speaker-diarized segments, `text` / `srt` / `json` | | Audio in chat output | — | 🟡 | Assistant response may contain `audio_url` content parts | -| Voice cloning ⭐ | ✅ | ✅ | `POST/GET/DELETE /v1/audio/voices` — register a named personalized voice from a WAV sample + transcript | +| Voice cloning ⭐ | ✅ | ✅ | `POST/GET/DELETE /v1/audio/voices` — register a named personalized voice from a WAV sample + transcript; the listing returns rich voice objects (Arabic display name, gender, accent, languages, emotion capability) and always includes the built-in public voices | | Machine translation | ✅ | ✅ | `POST /v1/translations` — EN↔AR, with HTML/whitespace-preserving preprocessing ⭐ | | Poetry generation ⭐ | — | ✅ | `POST /v1/poems/generations` — dedicated Arabic-poetry model | @@ -56,7 +56,10 @@ The core stays **universal**: no hard dependency on any framework, no JSON-libra Signals with **no counterpart** in the generic LLM vocabulary — the reason this SDK is more than a thin OpenAI-compatible client: - **Islamic RAG** — `message.references[]` = `{number, source, content}`; sources include `quran`, `tafsir`, `sunnah`, `dorar`, `islamweb*`, `islam_qa`, `islamonline`, `shamela`. -- **Scope knobs** for the RAG model — by book (`book_names`), by source (`preferred_sources` / `exclude_sources` / `filter_sources`), and a `restrict_to_islamic` guardrail that rejects non-Islamic prompts server-side. +- **Scope knobs** for the RAG models — by book (`book_names`), by source (`preferred_sources` / `exclude_sources` / `filter_sources`), by madhab (`madhab`: `all` / `hanafi` / `maliki` / `shafii` / `hanbali`, honoured by `Fanar-Sadiq-2`), and a `restrict_to_islamic` guardrail that rejects non-Islamic prompts server-side. +- **Custom persona** — free-form `persona` text controlling the assistant's voice and identity on `Fanar-Sadiq`. +- **Emotional TTS** — `with_emotion` synthesis on emotion-capable voices (`Abdulrahman`, `Radwa`). +- **Culturally-aligned prompt revision** — image generation auto-revises prompts for style, quality, and cultural alignment (server default on), reporting `revised` / `revised_prompt` per image. - **Bilingual progress events** mid-stream — `ProgressChunk.progress.message = {en, ar}`. - **Cultural-awareness moderation score**, separate from the standard safety score. - **Quranic TTS with validated reciters** — `quran_reciter ∈ {abdul-basit, maher-al-muaiqly, mahmoud-al-husary}`; the endpoint may return an `X-Revised-Input` header when the recitation text was normalized. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 019dcae..b5aa3cf 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -9,7 +9,7 @@ the links for depth. ## Fanar — the platform - **Fanar** — Qatar's Arabic-centric multimodal AI platform. Hosts all the models below. Base URL `https://api.fanar.qa`. -- **Fanar API** — the HTTP API this SDK targets. OpenAPI 3.1.0 spec committed at [`api-spec/openapi.json`](../api-spec/openapi.json): 12 endpoints, 14 models. +- **Fanar API** — the HTTP API this SDK targets. OpenAPI 3.1.0 spec committed at [`api-spec/openapi.json`](../api-spec/openapi.json) (normative; [`api-spec/openapi.yaml`](../api-spec/openapi.yaml) is its YAML twin): 12 endpoints, 15 models. - **OpenAI-compatible** — Fanar's chat endpoint accepts OpenAI-style request shapes. This SDK still exists because Fanar offers capabilities OpenAI does not (see [Compatibility matrix](COMPATIBILITY.md)). ## Fanar models @@ -22,7 +22,8 @@ Exact model IDs as accepted by the API. - **`Fanar-S-1-7B`** — "Star" chat model, 7 B parameters. - **`Fanar-C-1-8.7B`** — "Commander" chat model with thinking support, version 1. - **`Fanar-C-2-27B`** — "Commander" chat model with thinking support, version 2. Required for `enable_thinking=true` (with extra authorization). -- **`Fanar-Sadiq`** — Islamic RAG model. Returns authenticated source references. +- **`Fanar-Sadiq`** — Islamic RAG model. Returns authenticated source references. Accepts a custom `persona`. +- **`Fanar-Sadiq-2`** — madhab-aware Islamic RAG model, version 2. Honours the `madhab` filter; requires additional authorization. ### Vision @@ -34,7 +35,7 @@ Exact model IDs as accepted by the API. ### Speech -- **`Fanar-Aura-TTS-2`** — general text-to-speech. +- **`Fanar-Aura-TTS-2`** — general text-to-speech; supports chunked streaming and emotional synthesis on capable voices. - **`Fanar-Sadiq-TTS-1`** — Quranic text-to-speech with validated reciters. - **`Fanar-Aura-STT-1`** — speech-to-text for short clips (≤ 20–30 s). - **`Fanar-Aura-STT-LF-1`** — speech-to-text for long-form audio with speaker-diarized segments. @@ -58,7 +59,11 @@ Exact model IDs as accepted by the API. - **Quranic reciter** — one of `abdul-basit`, `maher-al-muaiqly`, `mahmoud-al-husary`. Selectable on `Fanar-Sadiq-TTS-1`. - **Voice cloning** — creating a named personalized voice from a WAV sample plus transcript. Endpoints under `/v1/audio/voices`. - **`restrict_to_islamic`** — a `Fanar-Sadiq` request flag that server-side rejects non-Islamic prompts. -- **Source scoping** (`preferred_sources`, `exclude_sources`, `filter_sources`, `book_names`) — `Fanar-Sadiq` controls that narrow retrieval to specific corpora. +- **Madhab** — Islamic school of jurisprudence (`hanafi`, `maliki`, `shafii`, `hanbali`, or `all`). The `madhab` request filter narrows `Fanar-Sadiq-2` retrieval to the chosen school(s). +- **Persona** — free-form request text (≤ 2000 chars) controlling the assistant's voice and identity; only `Fanar-Sadiq` honours it. +- **Emotional TTS** — `with_emotion` speech synthesis, available on `Fanar-Aura-TTS-2` with the emotion-capable voices (`Abdulrahman`, `Radwa`); other combinations are rejected with HTTP 422. +- **Prompt revision** — image generation's `revise` flag (server default on): Fanar rewrites the prompt for style, quality, and cultural alignment and reports `revised` / `revised_prompt` per image. +- **Source scoping** (`preferred_sources`, `exclude_sources`, `filter_sources`, `book_names`, `madhab`) — Sadiq-family controls that narrow retrieval to specific corpora or schools. ## Java / JVM terms @@ -99,6 +104,7 @@ Some words are overloaded — the Spring AI **`ChatModel`** type is unrelated to - **Advisor** — Spring AI's interceptor for the `ChatClient` chain. Runs `before()` to mutate the outbound prompt and `after()` to mutate the response. Memory and RAG are advisors. - **`ChatMemory`** — Spring AI's chat-history SPI. We use `MessageWindowChatMemory` (sliding window, in-memory) in the sample; production apps swap for the JDBC / Redis variants Spring AI ships. - **`MessageChatMemoryAdvisor`** — Spring AI's memory advisor. Loads prior messages into the prompt by `conversationId`, persists the response on the way out. +- **`FanarChatOptions` / `FanarTextToSpeechOptions` / `FanarImageOptions`** — Fanar-specific options classes (ADR-024) implementing Spring AI's portable options interfaces; the way to reach Fanar-only knobs (persona, madhab, thinking, RAG scoping, emotional TTS, prompt revision) from the Spring AI surface. ## Build / tooling terms diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 229f57f..5553046 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,32 +1,35 @@ # Project state -> **Snapshot — 2026-04-28.** Updated on every milestone. If this looks wrong or stale, that is +> **Snapshot — 2026-08-06.** Updated on every milestone. If this looks wrong or stale, that is > the signal — update it in the same PR as whatever moved. ## Phase -**Implementation phase — framework adapters shipping.** The core SDK and all Fanar domains -are feature-complete with 100 % JaCoCo coverage and live e2e tests against the real API. Spring -Boot 4 + Spring AI 2.0 starters are merged with sample apps. Pre-1.0; no Maven Central -artifacts yet. +**0.2.0 in flight — 2026-08 spec absorbed on `main`.** The refreshed Fanar OpenAPI spec +(downloaded 2026-08-05; `openapi.json` normative + new `openapi.yaml` twin) is fully modelled: +`Fanar-Sadiq-2` + `persona` + `madhab`, streamed + emotional TTS, rich voice catalogue, image +prompt revision, the 499 `client_closed_request` error, envelope-code error routing, and Spring +AI vendor options. All shipping modules hold the 100 % JaCoCo gate. Pre-1.0; no Maven Central +artifacts yet (0.1.0 shipped 2026-04-28 as a GitHub Release). ## Shipped | Layer | Module(s) | Highlights | |---|---|---| -| Core SDK | `fanar-core` | `FanarClient` + 8 typed domain facades (chat / models / tokens / moderations / translations / poems / images / audio). Sealed `FanarException` hierarchy. SSE streaming via `Flow.Publisher`. Sync + async + streaming. 100 % JaCoCo. | -| JSON codecs | `fanar-json-jackson2`, `fanar-json-jackson3` | Snake-case wire format, NON_NULL inclusion, six flattening deserializers, generic wire-value module, `ServiceLoader` discovery, GraalVM reachability metadata. | +| Core SDK | `fanar-core` | `FanarClient` + 8 typed domain facades (chat / models / tokens / moderations / translations / poems / images / audio). Sealed `FanarException` hierarchy (14 subtypes; mapper routes by envelope `error.code` with HTTP-status fallback). SSE streaming via `Flow.Publisher` + streamed TTS via `Flow.Publisher` (ADR-023). Sync + async + streaming. 2026-08 spec parity: `Fanar-Sadiq-2`, `persona`, `madhab`, `with_emotion`, rich `AvailableVoice` catalogue, image `revise`/`revised_prompt`. 100 % JaCoCo. | +| JSON codecs | `fanar-json-jackson2`, `fanar-json-jackson3` | Snake-case wire format, NON_NULL inclusion, six flattening deserializers, generic wire-value module (18 value classes incl. `Madhab`, `VoiceType`), `ServiceLoader` discovery, GraalVM reachability metadata. | | Observability | `fanar-obs-slf4j`, `fanar-obs-otel`, `fanar-obs-micrometer` | One adapter per backend; opt-in (no `ServiceLoader`). `ObservabilityPlugin.compose(...)` factory wires multiple adapters into a single slot. | | Interceptors | `fanar-interceptor-logging` | OkHttp-style level ladder (`NONE` / `BASIC` / `HEADERS` / `BODY`), SLF4J sink at `fanar.wire`, redaction, body cap, streaming-aware. | -| Live tests | `fanar-java-e2e` | Parameterized over both codecs. 19 chat-completion shapes × 2 codecs + every other domain (audio TTS+STT, voices, images, translations, moderations, tokens, models, poems). Gated on `FANAR_API_KEY`. | +| Live tests | `fanar-java-e2e` | Parameterized over both codecs. 21 chat-completion shapes × 2 codecs (incl. persona + gated Sadiq-2 madhab) + every other domain (audio TTS incl. streaming + emotion, STT on one shared clip, voices, images incl. revision, translations, moderations, tokens, models, poems). Gated on `FANAR_API_KEY`. | | GraalVM | `fanar-java-e2e-graalvm` | Fat-jar + `native-image` profile. Self-test mode (offline: 9 decode + 9 encode probes + obs plugins + interceptor) and live mode covering every domain. CI: PR-time native-smoke + workflow-dispatch metadata bootstrap. | | Spring Boot 4 | `fanar-spring-boot-4-starter`, `fanar-spring-boot-4-sample` | `@AutoConfiguration` + typed `FanarProperties` record + auto-wired `Interceptor` / `ObservabilityPlugin` beans + `FanarHealthIndicator` (Actuator). Sample app exercises the wiring end-to-end. | -| Spring AI 2.0 | `fanar-spring-ai-starter`, `fanar-spring-ai-sample` | `ChatModel` (with streaming) + `ImageModel` + `TextToSpeechModel` + `TranscriptionModel` adapters, depending on the SB4 starter. Sample uses `ChatClient` with `MessageChatMemoryAdvisor` for multi-turn. Pinned to Spring AI `2.0.0-M4`. | +| Spring AI 2.0 | `fanar-spring-ai-starter`, `fanar-spring-ai-sample` | `ChatModel` (real token streaming) + `ImageModel` (revision metadata + `created`) + `TextToSpeechModel` (real chunk streaming) + `TranscriptionModel` adapters, plus vendor options `FanarChatOptions` / `FanarTextToSpeechOptions` / `FanarImageOptions` (ADR-024). Sample uses `ChatClient` with `MessageChatMemoryAdvisor` for multi-turn. Spring AI `2.0.0`. | | Build / CI | parent POM, `.github/workflows/ci.yml` | Java 21 + 25 matrix, JaCoCo 100 % gate on every shipping module, `dependency:analyze` strict mode, doclint at javac time, JaCoCo report uploaded as artifact on failure for flake diagnosis, `-parameters` flag enabled globally. | ## Planned -- **Maven Central publication** — Sonatype account, GPG signing, release workflow, version-bump policy. Gates v0.1.0. +- **0.2.0 release** — Pattern B release-and-bump flow once the spec-parity PRs land on `main`. +- **Maven Central publication** — Sonatype account, GPG signing, release workflow, version-bump policy. (Intro email to the Fanar team sent 2026-05-01; awaiting Sonatype-path pointer.) - **Spring Boot 3 starter** — `fanar-spring-boot-3-starter` with the Jackson 2 codec; mechanical port of the SB4 starter. - **LangChain4j adapter** — `fanar-langchain4j` exposing the equivalent of Spring AI's adapters against LangChain4j's `ChatLanguageModel`. - **Quarkus extension** — CDI beans, build-time wiring, native-image friendliness. diff --git a/docs/adr/006-unchecked-exception-hierarchy.md b/docs/adr/006-unchecked-exception-hierarchy.md index 4be89a2..78b336e 100644 --- a/docs/adr/006-unchecked-exception-hierarchy.md +++ b/docs/adr/006-unchecked-exception-hierarchy.md @@ -1,6 +1,6 @@ # ADR-006 — Unchecked exception hierarchy -- **Status**: Accepted +- **Status**: Accepted (amended 2026-08-05 — see [Amendments](#amendments)) - **Date**: 2026-04-23 - **Deciders**: @omahjoub (initial design) @@ -8,8 +8,9 @@ The Fanar API returns a typed `ErrorCode` enumeration (`content_filter`, `invalid_authentication`, `rate_limit_reached`, `exceeded_quota`, `internal_server_error`, `overloaded`, `timeout`, `too_large`, `unprocessable`, `conflict`, -`Not found`, `no_longer_supported`). Transport failures (`IOException`, `InterruptedException`) from JDK `HttpClient` -are a separate category. We must decide how these errors surface to Java callers. +`Not found`, `no_longer_supported`, `client_closed_request`). Transport failures (`IOException`, +`InterruptedException`) from JDK `HttpClient` are a separate category. We must decide how these errors surface to +Java callers. The choice interacts with our async (`CompletableFuture`) and streaming (`Flow.Publisher`) surfaces — both of which have well-defined error channels that compose cleanly with `RuntimeException` subtypes but fight checked @@ -26,7 +27,7 @@ public abstract sealed class FanarException extends RuntimeException public sealed class FanarClientException extends FanarException permits FanarAuthenticationException, FanarAuthorizationException, FanarQuotaExceededException, FanarNotFoundException, FanarConflictException, FanarTooLargeException, - FanarUnprocessableException, FanarGoneException { … } + FanarUnprocessableException, FanarGoneException, FanarClientClosedRequestException { … } public sealed class FanarServerException extends FanarException permits FanarRateLimitException, FanarOverloadedException, FanarTimeoutException, FanarInternalServerException { … } @@ -82,10 +83,30 @@ at the transport boundary; callers never see JDK checked exceptions on the publi - Fanar-specific metadata (retry-after seconds, filter type, rate-limit window) lives as fields on the relevant subtype, retrievable via typed accessors. +## Amendments + +### 2026-08-05 — `client_closed_request` and envelope-code routing (0.2.0) + +The Fanar spec added a fourteenth error code, `client_closed_request` (HTTP 499, declared on every +endpoint). Following this ADR's one-subtype-per-`ErrorCode` rule, 0.2.0 adds +`FanarClientClosedRequestException` as a leaf under `FanarClientException` — deliberately *not* a +new top-level branch, so retry classification (`RetryPolicy.isDefaultRetryable`) and consumer +switches over the four top-level categories keep compiling, and the new code is correctly +non-retryable. Adding a permit to a sealed class is a breaking change under JLBP-10; ADR-019's +pre-1.0 policy allows it in a minor release with a changelog callout, which 0.2.0 carries. + +The same release implements the routing this ADR always implied: `ExceptionMapper` now parses the +Fanar error envelope (`{"error":{"code":…,"message":…,"status":…}}`) and routes by the typed +`ErrorCode` first, falling back to HTTP status when the body is not a well-formed envelope or +carries an unknown code. This makes `FanarQuotaExceededException` reachable (both quota exhaustion +and throttling wire as HTTP 429) and stops non-filter 400s from surfacing as +`FanarContentFilterException`. + ## References - ADR-004 Sync-primary API with async sugar - ADR-005 Streaming via `Flow.Publisher` - ADR-007 JDK `HttpClient` as the default transport - ADR-014 Retry policy defaults (consumes the typed hierarchy) +- ADR-019 Pre-1.0 stability policy (permits the 0.2.0 sealed-hierarchy addition) - OpenAPI spec § `ErrorCode`, `ErrorStatus` diff --git a/docs/adr/023-streaming-tts-via-flow-publisher.md b/docs/adr/023-streaming-tts-via-flow-publisher.md new file mode 100644 index 0000000..56a4b0a --- /dev/null +++ b/docs/adr/023-streaming-tts-via-flow-publisher.md @@ -0,0 +1,82 @@ +# ADR-023 — Streaming TTS via `Flow.Publisher` + +- **Status**: Accepted +- **Date**: 2026-08-06 +- **Deciders**: @omahjoub + +## Context + +The 2026-08 Fanar spec added a `stream` flag to `POST /v1/audio/speech`: when `true`, the server +delivers the synthesized audio (mp3 or wav) chunked as it is generated, instead of buffering the +whole clip. The SDK must expose this without breaking the buffered `speech(...) → byte[]` path. + +Constraints already in place: + +- ADR-005 pinned `java.util.concurrent.Flow` as the streaming idiom (chat SSE: + `Flow.Publisher`), with JDK-only types on the public surface (ADR-003). +- The transport (ADR-007) already hands every domain client a lazy + `HttpResponse` (`BodyHandlers.ofInputStream()`); the buffered path simply drains + it eagerly. Streaming is therefore a new consumption surface, not transport work. +- Chat deliberately does not model the wire field `stream` on `ChatRequest` — the call-site + method (`send` vs `stream`) decides, and the transport splices `"stream":true` into the + serialized body. `TextToSpeechRequest` mirrors that posture. +- Spring AI's `TextToSpeechModel` extends `StreamingTextToSpeechModel`; before this ADR our + adapter faked `stream(...)` by wrapping the one-shot result in a single-element `Flux`. + +## Decision + +1. **`AudioClient` gains `Flow.Publisher speechStream(TextToSpeechRequest)`.** The + request record stays free of a `stream` component; the implementation splices + `"stream":true` into the encoded body via the shared internal `StreamFlag` helper (extracted + from the chat implementation) and hands the response body to a new internal + `AudioStreamPublisher`. +2. **Chunks are opaque `byte[]`.** Boundaries follow transport reads (8 KiB buffer) and carry + no semantic meaning; subscribers concatenate chunks in emission order to reconstruct the + clip. No container-aware framing — the SDK does not parse mp3 frames or wav blocks. +3. **`AudioStreamPublisher` is the structural twin of `SseStreamPublisher`**: single + subscriber, demand-gated emission on a virtual thread, cancel closes the connection, + `InterruptedException` wrapped in `FanarTransportException`, other failures passed to + `onError` as-is. Interceptors and error mapping apply to the initial exchange only, exactly + like chat streaming. +4. **The Spring AI adapter streams for real**: `FanarTextToSpeechModel.stream(...)` bridges the + publisher with `JdkFlowAdapter.flowPublisherToFlux` (the same idiom `FanarChatModel` uses) + and emits one `TextToSpeechResponse` per chunk. + +## Alternatives considered + +- **`InputStream` return** (`speechStream` → `InputStream`). *Rejected*: simple, but breaks the + ADR-005 idiom, offers no back-pressure contract, and adapts worse to reactive consumers + (Spring AI's `Flux`, RxJava) — the main audience for streaming TTS. +- **Modelling `stream` as a `TextToSpeechRequest` component.** *Rejected*: invites the invalid + `stream=true` + `speech()` combination and contradicts the chat precedent; the return type, + not a request flag, is the honest signal of delivery mode. +- **A typed chunk event (e.g. `AudioChunk` record with index/offset).** *Rejected*: the wire + provides no chunk metadata to model — wrapping `byte[]` in a record adds allocation and API + surface for zero information. +- **Deferring streaming to 0.3.0.** *Rejected*: the transport already supports it, the Spring AI + streaming surface exists and was semantically a lie, and 0.2.0's goal is full spec parity. + +## Consequences + +### Positive +- Full spec parity for TTS delivery modes; time-to-first-audio drops for long inputs. +- `FanarTextToSpeechModel.stream(...)` honours its interface contract instead of faking it. +- Back-pressure and cancellation semantics match chat streaming — one mental model. + +### Negative / Trade-offs +- Chunk boundaries are transport artifacts; players that need whole containers must buffer + anyway. Documented on the method. +- A second publisher implementation to maintain — mitigated by keeping it a line-for-line twin + of the SSE one minus frame assembly. + +### Neutral +- Observability reuses the `fanar.audio.speech` operation name (chat streaming reuses + `fanar.chat` the same way); the initial exchange is observed, mid-stream reads are not. + +## References + +- ADR-005 Streaming via `Flow.Publisher` +- ADR-007 JDK `HttpClient` as the default transport +- ADR-012 Interceptor SPI (handshake-only application to streams) +- ADR-021 Spring AI 2.0 adapter +- OpenAPI spec § `TextToSpeechRequest.stream` diff --git a/docs/adr/024-spring-ai-vendor-options.md b/docs/adr/024-spring-ai-vendor-options.md new file mode 100644 index 0000000..f172cef --- /dev/null +++ b/docs/adr/024-spring-ai-vendor-options.md @@ -0,0 +1,83 @@ +# ADR-024 — Spring AI vendor options (`FanarChatOptions` family) + +- **Status**: Accepted +- **Date**: 2026-08-06 +- **Deciders**: @omahjoub + +## Context + +Spring AI's portable options interfaces deliberately carry only the lowest-common-denominator +knobs: `ChatOptions` has 8 getters, `TextToSpeechOptions` 4, `ImageOptions` 6. Fanar's chat +endpoint alone accepts ~25 more parameters — persona, madhab, `enable_thinking`, +`restrict_to_islamic`, the Islamic-RAG scoping lists, `logit_bias`, and the vLLM sampling +knobs — and the audio/image endpoints have `with_emotion` / `quran_reciter` / `revise`. Until +0.2.0 none of these were reachable through the Spring AI surface; users had to drop down to the +auto-wired `FanarClient` bean. + +Every major Spring AI provider solves this the same way: a provider-specific options class +implementing the portable interface (e.g. `OpenAiChatOptions`), which the model adapter narrows +via `instanceof`. + +## Decision + +Three immutable, builder-based options classes in `qa.fanar.spring.ai`: + +1. **`FanarChatOptions implements ChatOptions`** — the 8 portable fields plus every + `ChatRequest` knob portable options lack. Typed with core value classes (`Madhab`, + `BookName`, `Source`) rather than raw strings. +2. **`FanarTextToSpeechOptions implements TextToSpeechOptions`** — portable fields plus + `withEmotion` and `quranReciter` (the latter was previously hardcoded `null` in the adapter). +3. **`FanarImageOptions implements ImageOptions`** — portable fields plus `revise`. + +Merge semantics are **portable-first**: the adapters keep mapping the portable getters for any +`*Options` implementation, then apply the Fanar extras only when the instance is the Fanar +subtype (`instanceof` narrowing), and only for non-null fields. A non-Fanar options instance +behaves exactly as before. Model-specific validation stays server-side (ADR-015); the options +classes perform none. + +`FanarChatOptions.Builder` **extends Spring AI's `DefaultChatOptionsBuilder`** — this matters +because `ChatClient.prompt(Prompt)` rebuilds request options via `options.mutate()…build()`. +Our `mutate()` returns the Fanar builder pre-populated with *all* fields, so the extras survive +the fluent pipeline instead of being silently flattened to portable options. The +`combineWith(...)` override merges the extras when both builders are Fanar builders (non-null +values from the other builder win; extras collections replace rather than concatenate — they +are filters, and appending two filters is not a meaningful union — while the portable fields +keep Spring AI's own merge rules, including stop-sequence concatenation). + +## Alternatives considered + +- **Do nothing (core-client escape hatch only).** *Rejected by scope decision for 0.2.0*: the + new spec capabilities (persona, madhab, emotion, revise) deserve first-class reach from the + framework surface users actually hold. +- **String-typed extras** (mirroring how some providers expose raw maps). *Rejected*: the SDK + already owns typed open value classes; dropping to strings at the framework boundary throws + away compile-time safety for no interop gain. +- **A generic `Map extraBody`.** *Rejected*: undiscoverable, untyped, and + bypasses `ChatRequest` validation. +- **Registering the options as auto-configured default beans.** *Rejected for now*: defaults + remain constructor arguments on the model adapters; per-call options are the Spring AI idiom. + +## Consequences + +### Positive +- Full framework-level parity with the Fanar wire surface; no more dropping to `FanarClient` + for provider knobs. +- The `instanceof` pattern matches the wider Spring AI ecosystem — no new mental model. + +### Negative / Trade-offs +- `FanarChatOptions` is a wide class (~32 fields) that must track `ChatRequest` — a new + `ChatRequest` knob now lands in two places. Accepted: both live in this repo and the codec + wire tests catch drift. +- Spring AI's `ChatClient` merging utilities only understand portable fields; Fanar extras + survive only when the Fanar options instance itself reaches the model (the normal path). + +### Neutral +- Properties-file binding (`spring.ai.*`-style option defaults) is not provided; options are + programmatic. Revisit if users ask. + +## References + +- ADR-015 Hand-written DTO conventions (validation stays server-side / request-side) +- ADR-021 Spring AI 2.0 adapter +- ADR-023 Streaming TTS via `Flow.Publisher` +- Spring AI `OpenAiChatOptions` — the ecosystem precedent diff --git a/docs/adr/INDEX.md b/docs/adr/INDEX.md index d74919b..63c9472 100644 --- a/docs/adr/INDEX.md +++ b/docs/adr/INDEX.md @@ -33,6 +33,7 @@ Every ADR follows an extended Michael Nygard template: - [006 — Unchecked exception hierarchy](006-unchecked-exception-hierarchy.md) - [015 — Hand-written DTO conventions](015-dto-conventions.md) - [016 — FanarClient builder and domain facades](016-fanarclient-builder-domain-facades.md) +- [023 — Streaming TTS via Flow.Publisher<byte[]>](023-streaming-tts-via-flow-publisher.md) ### Transport and serialization @@ -57,3 +58,4 @@ Every ADR follows an extended Michael Nygard template: - [020 — Spring Boot 4 starter shape](020-spring-boot-4-starter.md) - [021 — Spring AI 2.0 adapter](021-spring-ai-2-adapter.md) - [022 — Observability composition via `compose(...)` factory](022-observability-compose-factory.md) +- [024 — Spring AI vendor options (FanarChatOptions family)](024-spring-ai-vendor-options.md) diff --git a/docs/images/fanar_java_runtime_architecture.svg b/docs/images/fanar_java_runtime_architecture.svg index 5da610c..844d7b3 100644 --- a/docs/images/fanar_java_runtime_architecture.svg +++ b/docs/images/fanar_java_runtime_architecture.svg @@ -62,5 +62,5 @@ -Fanar API — https://api.fanar.qa · 12 endpoints · 14 models +Fanar API — https://api.fanar.qa · 12 endpoints · 15 models \ No newline at end of file diff --git a/e2e-graalvm/src/main/java/qa/fanar/e2e/graalvm/Main.java b/e2e-graalvm/src/main/java/qa/fanar/e2e/graalvm/Main.java index db6f32f..d6544e4 100644 --- a/e2e-graalvm/src/main/java/qa/fanar/e2e/graalvm/Main.java +++ b/e2e-graalvm/src/main/java/qa/fanar/e2e/graalvm/Main.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; import io.micrometer.observation.ObservationRegistry; @@ -28,6 +29,7 @@ import qa.fanar.core.chat.ChatModel; import qa.fanar.core.chat.ChatRequest; import qa.fanar.core.chat.ChatResponse; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.UserMessage; import qa.fanar.core.images.ImageGenerationRequest; import qa.fanar.core.images.ImageGenerationResponse; @@ -183,17 +185,24 @@ private static void decodePoems(FanarJsonCodec codec) throws IOException { private static void decodeImages(FanarJsonCodec codec) throws IOException { String wire = "{\"id\":\"req_1\",\"created\":1700000000," - + "\"data\":[{\"b64_json\":\"aGVsbG8=\"}]}"; + + "\"data\":[{\"b64_json\":\"aGVsbG8=\",\"revised\":true," + + "\"revised_prompt\":\"a refined sunset\"}]}"; ImageGenerationResponse r = codec.decode(bytes(wire), ImageGenerationResponse.class); require(r.data().size() == 1, "image data size"); require("aGVsbG8=".equals(r.data().getFirst().b64Json()), "image b64"); + require(r.data().getFirst().revised(), "image revised flag"); } private static void decodeAudioVoices(FanarJsonCodec codec) throws IOException { VoiceResponse r = codec.decode( - bytes("{\"voices\":[\"alice\",\"bob\"]}"), + bytes("{\"voices\":[{\"name\":\"Amelia\",\"name_ar\":\"\\u0623\\u0645\\u064a\\u0644\\u064a\\u0627\"," + + "\"gender\":\"Female\",\"accent\":\"British\",\"languages\":[\"en\"]," + + "\"type\":\"public\",\"emotion\":false}," + + "{\"name\":\"MyVoice\",\"languages\":[],\"type\":\"personal\",\"emotion\":false}]}"), VoiceResponse.class); require(r.voices().size() == 2, "voices size"); + require("Amelia".equals(r.voices().getFirst().name()), "voice name"); + require(qa.fanar.core.audio.VoiceType.PERSONAL.equals(r.voices().get(1).type()), "voice type"); } private static void decodeAudioStt(FanarJsonCodec codec) throws IOException { @@ -220,6 +229,8 @@ private static void encodeChatRequest(FanarJsonCodec codec) throws IOException { .addMessage(UserMessage.of("hi")) .maxTokens(8) .temperature(0.0) + .persona("teacher") + .madhab(List.of(Madhab.HANAFI)) .build(); byte[] body = encode(codec, req); require(body.length > 0, "chat encode"); @@ -249,14 +260,18 @@ private static void encodePoemGenerationRequest(FanarJsonCodec codec) throws IOE } private static void encodeImageGenerationRequest(FanarJsonCodec codec) throws IOException { - byte[] body = encode(codec, ImageGenerationRequest.of( - ImageModel.FANAR_ORYX_IG_2, "a sunset")); + byte[] body = encode(codec, new ImageGenerationRequest( + ImageModel.FANAR_ORYX_IG_2, "a sunset", Boolean.TRUE)); require(body.length > 0, "images encode"); } private static void encodeTextToSpeechRequest(FanarJsonCodec codec) throws IOException { - byte[] body = encode(codec, TextToSpeechRequest.of( - TtsModel.FANAR_AURA_TTS_2, "hello", Voice.HARRY)); + byte[] body = encode(codec, TextToSpeechRequest.builder() + .model(TtsModel.FANAR_AURA_TTS_2) + .input("hello") + .voice(Voice.RADWA) + .withEmotion(true) + .build()); require(body.length > 0, "tts encode"); } @@ -447,7 +462,7 @@ private static byte[] liveAudioSpeech(FanarClient client) { // Probes the binary-response path — `BodyHandlers.ofInputStream` + `byte[]` return. byte[] wav = client.audio().speech(new TextToSpeechRequest( TtsModel.FANAR_AURA_TTS_2, "hello", Voice.HARRY, - qa.fanar.core.audio.TtsResponseFormat.WAV, null)); + qa.fanar.core.audio.TtsResponseFormat.WAV, null, null)); System.out.println(" audio.speech: bytes=" + wav.length); return wav; } diff --git a/e2e/src/test/java/qa/fanar/e2e/AdapterParityTest.java b/e2e/src/test/java/qa/fanar/e2e/AdapterParityTest.java index 49393ae..4a5156c 100644 --- a/e2e/src/test/java/qa/fanar/e2e/AdapterParityTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/AdapterParityTest.java @@ -9,11 +9,19 @@ import org.junit.jupiter.api.Test; +import qa.fanar.core.audio.AvailableVoice; import qa.fanar.core.audio.SpeechToTextResponse; +import qa.fanar.core.audio.TextToSpeechRequest; +import qa.fanar.core.audio.TtsModel; +import qa.fanar.core.audio.TtsResponseFormat; +import qa.fanar.core.audio.Voice; +import qa.fanar.core.audio.VoiceResponse; +import qa.fanar.core.audio.VoiceType; import qa.fanar.core.chat.AssistantMessage; import qa.fanar.core.chat.ChatModel; import qa.fanar.core.chat.ChatRequest; import qa.fanar.core.chat.ChatResponse; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.SystemMessage; import qa.fanar.core.chat.ToolCall; import qa.fanar.core.chat.UserMessage; @@ -68,6 +76,8 @@ void chatRequestJsonShapeIsIdenticalAcrossAdapters() throws IOException { .logprobs(true) .topLogprobs(3) .enableThinking(true) + .persona("Warm, patient teacher") + .madhab(List.of(Madhab.HANAFI)) .build(); Map shape2 = parseAsMap(encode(jackson2, request)); @@ -259,9 +269,11 @@ void imageGenerationRequestEncodesIdenticallyAcrossAdapters() throws IOException @Test void imageGenerationResponseDecodesIdenticallyAcrossAdapters() throws IOException { - // Wire shape mirrors the spec: id, created, data[].b64_json (snake-case maps to b64Json). + // Wire shape mirrors the spec: id, created, data[].{b64_json, revised, revised_prompt} + // (all three required per the 2026-08 spec). String wire = "{\"id\":\"req_1\",\"created\":1700000000," - + "\"data\":[{\"b64_json\":\"aGVsbG8=\"}]}"; + + "\"data\":[{\"b64_json\":\"aGVsbG8=\",\"revised\":true," + + "\"revised_prompt\":\"a refined sunset\"}]}"; ImageGenerationResponse decoded2 = jackson2.decode(bytes(wire), ImageGenerationResponse.class); ImageGenerationResponse decoded3 = jackson3.decode(bytes(wire), ImageGenerationResponse.class); assertEquals(decoded2, decoded3, @@ -271,6 +283,8 @@ void imageGenerationResponseDecodesIdenticallyAcrossAdapters() throws IOExceptio assertEquals(1, decoded3.data().size()); ImageGenerationItem item = decoded3.data().getFirst(); assertEquals("aGVsbG8=", item.b64Json()); + assertTrue(item.revised()); + assertEquals("a refined sunset", item.revisedPrompt()); } @Test @@ -320,6 +334,50 @@ void speechToTextResponseJsonVariantDecodesIdenticallyAcrossAdapters() throws IO assertEquals("hello", json.segments().getFirst().text()); } + @Test + void textToSpeechRequestEncodesIdenticallyAcrossAdapters() throws IOException { + TextToSpeechRequest req = TextToSpeechRequest.builder() + .model(TtsModel.FANAR_AURA_TTS_2) + .input("hello") + .voice(Voice.RADWA) + .responseFormat(TtsResponseFormat.WAV) + .withEmotion(true) + .build(); + Map shape2 = parseAsMap(encode(jackson2, req)); + Map shape3 = parseAsMap(encode(jackson3, req)); + assertEquals(shape2, shape3, + "TextToSpeechRequest must encode to the same JSON shape via both adapters"); + assertEquals("Fanar-Aura-TTS-2", shape3.get("model")); + assertEquals("Radwa", shape3.get("voice")); + assertEquals("wav", shape3.get("response_format")); + assertEquals(true, shape3.get("with_emotion")); + } + + @Test + void voiceResponseDecodesIdenticallyAcrossAdapters() throws IOException { + // Wire shape mirrors the 2026-08 spec: rich voice objects with snake-case name_ar and + // the public/personal type discriminator. + String wire = "{\"voices\":[" + + "{\"name\":\"Amelia\",\"name_ar\":\"أميليا\",\"gender\":\"Female\"," + + "\"accent\":\"British\",\"languages\":[\"en\"],\"type\":\"public\",\"emotion\":false}," + + "{\"name\":\"MyVoice\",\"languages\":[],\"type\":\"personal\",\"emotion\":false}" + + "]}"; + VoiceResponse decoded2 = jackson2.decode(bytes(wire), VoiceResponse.class); + VoiceResponse decoded3 = jackson3.decode(bytes(wire), VoiceResponse.class); + assertEquals(decoded2, decoded3, + "VoiceResponse decoded by both adapters must be record-equal"); + assertEquals(2, decoded3.voices().size()); + AvailableVoice amelia = decoded3.voices().getFirst(); + assertEquals("Amelia", amelia.name()); + assertEquals("أميليا", amelia.nameAr()); + assertEquals("Female", amelia.gender()); + assertEquals("British", amelia.accent()); + assertEquals(VoiceType.PUBLIC, amelia.type()); + AvailableVoice personal = decoded3.voices().get(1); + assertEquals(VoiceType.PERSONAL, personal.type()); + assertTrue(personal.languages().isEmpty()); + } + @Test void modelsResponseDecodesIdenticallyAcrossAdapters() throws IOException { // A canned shape mirroring what the live /v1/models endpoint emits, including the diff --git a/e2e/src/test/java/qa/fanar/e2e/Probes.java b/e2e/src/test/java/qa/fanar/e2e/Probes.java index 737f2cc..c614e68 100644 --- a/e2e/src/test/java/qa/fanar/e2e/Probes.java +++ b/e2e/src/test/java/qa/fanar/e2e/Probes.java @@ -6,6 +6,7 @@ import qa.fanar.core.chat.BookName; import qa.fanar.core.chat.ChatModel; import qa.fanar.core.chat.ChatRequest; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.SystemMessage; import qa.fanar.core.chat.UserMessage; @@ -130,6 +131,43 @@ public static ChatRequest sadiqWithBookName() { .build(); } + /** + * Persona probe — sets a custom assistant persona on {@code Fanar-Sadiq}, the only model the + * spec documents as honouring {@code persona}. The companion test asserts wire acceptance + * (HTTP 200) and a text reply; whether the voice actually shifts is subjective and not + * asserted. + */ + public static ChatRequest sadiqWithPersona() { + return ChatRequest.builder() + .model(ChatModel.FANAR_SADIQ) + .addMessage(UserMessage.of("Briefly summarise the meaning of Surah Al-Fatihah.")) + .restrictToIslamic(true) + .persona("You are a warm, patient teacher who explains concepts simply for young students.") + .maxTokens(96) + .temperature(0.0) + .build(); + } + + /** + * Madhab-filtered probe for the madhab-aware Islamic RAG model. + * + *

    Gating caveat: per the Fanar spec, {@code Fanar-Sadiq-2} requires + * additional authorization and is not allowed by default. Observed 2026-08-06: the model + * gate answers HTTP 422 {@code unprocessable} / "Model not authorized" (not 403), so until + * our key is upgraded the companion test surfaces {@code FanarUnprocessableException} and + * fails loudly — the desired diagnostic signal, not a flake. Share the wire log when it + * happens.

    + */ + public static ChatRequest sadiq2WithMadhab() { + return ChatRequest.builder() + .model(ChatModel.FANAR_SADIQ_2) + .addMessage(UserMessage.of("What are the conditions for Zakat on gold?")) + .madhab(List.of(Madhab.HANAFI)) + .maxTokens(96) + .temperature(0.0) + .build(); + } + /** {@code n=3} multi-choice probe — verifies the response carries three independent choices. */ public static ChatRequest tripleChoice() { return ChatRequest.builder() diff --git a/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioSpeechTest.java b/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioSpeechTest.java index a8959d0..b88e1a2 100644 --- a/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioSpeechTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioSpeechTest.java @@ -1,6 +1,10 @@ package qa.fanar.e2e.audio; +import java.io.ByteArrayOutputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; @@ -23,6 +27,7 @@ import qa.fanar.json.jackson3.Jackson3FanarJsonCodec; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -72,7 +77,7 @@ void speech_returnsWavAudioBytes(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { byte[] audio = client.audio().speech(new TextToSpeechRequest( TtsModel.FANAR_AURA_TTS_2, "نحن بنات طارق نمشي على النمارق", - Voice.HUDA, TtsResponseFormat.WAV, null)); + Voice.HUDA, TtsResponseFormat.WAV, null, null)); assertNotNull(audio); assertTrue(audio.length > 12, "WAV minimum header is 12 bytes, got " + audio.length); @@ -86,6 +91,63 @@ void speech_returnsWavAudioBytes(FanarJsonCodec codec) { } } + @ParameterizedTest(name = "[{0}]") + @MethodSource("codecs") + @DisplayName("§M.7b speechStream (wav) delivers chunks that concatenate to a RIFF/WAVE clip") + void speechStream_deliversWavChunks(FanarJsonCodec codec) throws Exception { + try (FanarClient client = TestClients.liveWithLogging(codec)) { + Flow.Publisher publisher = client.audio().speechStream(TextToSpeechRequest.builder() + .model(TtsModel.FANAR_AURA_TTS_2) + .input("مرحبا بكم في فنار") + .voice(Voice.HAMAD) + .responseFormat(TtsResponseFormat.WAV) + .build()); + + ByteArrayOutputStream collected = new ByteArrayOutputStream(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + publisher.subscribe(new Flow.Subscriber() { + public void onSubscribe(Flow.Subscription s) { s.request(Long.MAX_VALUE); } + public void onNext(byte[] chunk) { collected.writeBytes(chunk); } + public void onError(Throwable t) { failure.set(t); done.countDown(); } + public void onComplete() { done.countDown(); } + }); + + assertTrue(done.await(60, TimeUnit.SECONDS), "stream must terminate within 60s"); + assertNull(failure.get(), () -> "stream errored: " + failure.get()); + byte[] audio = collected.toByteArray(); + assertTrue(audio.length > 12, "WAV minimum header is 12 bytes, got " + audio.length); + assertTrue(audio[0] == 'R' && audio[1] == 'I' && audio[2] == 'F' && audio[3] == 'F', + "expected RIFF prefix on the concatenated stream"); + + LiveOutputs.write("audio-output", "speech-stream-wav", "wav", audio); + } + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("codecs") + @DisplayName("§M.7b speech with with_emotion=true on an emotion-capable voice (Radwa) returns audio") + void speech_withEmotionOnCapableVoice(FanarJsonCodec codec) { + // Radwa and Abdulrahman are the two emotion-capable built-ins per the 2026-08 spec. + // An emotion-incapable voice or Fanar-Sadiq-TTS-1 would be rejected with HTTP 422. + try (FanarClient client = TestClients.liveWithLogging(codec)) { + byte[] audio = client.audio().speech(TextToSpeechRequest.builder() + .model(TtsModel.FANAR_AURA_TTS_2) + .input("يا لها من ليلة جميلة!") + .voice(Voice.RADWA) + .responseFormat(TtsResponseFormat.WAV) + .withEmotion(true) + .build()); + + assertNotNull(audio, "audio bytes must be present"); + assertTrue(audio.length > 12, "WAV minimum header is 12 bytes, got " + audio.length); + assertTrue(audio[0] == 'R' && audio[1] == 'I' && audio[2] == 'F' && audio[3] == 'F', + "expected RIFF prefix for emotional synthesis output"); + + LiveOutputs.write("audio-output", "speech-radwa-emotion-wav", "wav", audio); + } + } + @ParameterizedTest(name = "[{0}]") @MethodSource("codecs") @DisplayName("§M.7b speechAsync().get() completes against live infra with MP3 audio bytes") diff --git a/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioTranscriptionTest.java b/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioTranscriptionTest.java index dc2a50f..69f09df 100644 --- a/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioTranscriptionTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioTranscriptionTest.java @@ -34,9 +34,12 @@ * Live battle-test of {@code POST /v1/audio/transcriptions} via {@link FanarClient#audio()}, * parameterized over both codec adapters. * - *

    Each test synthesises a short WAV clip via {@link FanarClient#audio()} {@code .speech(...)} - * (the M.7b endpoint already battle-tested) and immediately transcribes that clip — a tight - * round-trip that proves the entire audio pipeline end-to-end.

    + *

    All tests transcribe one shared WAV clip, synthesised lazily on first use via + * {@code .speech(...)} (the M.7b endpoint already battle-tested). Previously every test + * synthesised its own clip — 8 TTS calls per live run on top of the speech suite — which + * tripped the audio endpoints' shared rate limit (429s). The clip's bytes come from the server, + * not from the codec under test, so sharing it does not weaken the per-codec transcription + * coverage.

    * *

    No silent catches per the fail-loudly preference — server errors surface verbatim with the * wire log.

    @@ -49,23 +52,37 @@ class LiveAudioTranscriptionTest { private static final String ARABIC_PROMPT = "السلام عليكم ورحمة الله وبركاته"; + private static byte[] sharedWav; + static Stream codecs() { return Stream.of( Arguments.of(Named.of("jackson2", new Jackson2FanarJsonCodec())), Arguments.of(Named.of("jackson3", new Jackson3FanarJsonCodec()))); } + /** One TTS call per JVM run; the codec choice for synthesis is arbitrary (server produces the bytes). */ + private static synchronized byte[] sourceClip() { + if (sharedWav == null) { + try (FanarClient client = TestClients.liveWithLogging(new Jackson3FanarJsonCodec())) { + sharedWav = client.audio().speech(TextToSpeechRequest.builder() + .model(TtsModel.FANAR_AURA_TTS_2) + .input(ARABIC_PROMPT) + .voice(Voice.HUDA) + .responseFormat(TtsResponseFormat.WAV) + .build()); + LiveOutputs.write("audio-output", "stt-source-shared", "wav", sharedWav); + } + } + return sharedWav; + } + @ParameterizedTest(name = "[{0}]") @MethodSource("codecs") @DisplayName("§M.7c transcribe (format=text) returns Text variant with non-empty body") void transcribe_textVariant(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { - byte[] wav = client.audio().speech(new TextToSpeechRequest( - TtsModel.FANAR_AURA_TTS_2, ARABIC_PROMPT, Voice.HUDA, TtsResponseFormat.WAV, null)); - LiveOutputs.write("audio-output", "stt-source-text", "wav", wav); - SpeechToTextResponse response = client.audio().transcribe(new TranscriptionRequest( - wav, "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_1, SttFormat.TEXT)); + sourceClip(), "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_1, SttFormat.TEXT)); SpeechToTextResponse.Text text = assertInstanceOf(SpeechToTextResponse.Text.class, response, "format=text must produce a Text variant"); @@ -79,11 +96,8 @@ void transcribe_textVariant(FanarJsonCodec codec) { @DisplayName("§M.7c transcribe (format=srt, long-form model) returns Srt variant") void transcribe_srtVariant(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { - byte[] wav = client.audio().speech(new TextToSpeechRequest( - TtsModel.FANAR_AURA_TTS_2, ARABIC_PROMPT, Voice.HUDA, TtsResponseFormat.WAV, null)); - SpeechToTextResponse response = client.audio().transcribe(new TranscriptionRequest( - wav, "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_LF_1, SttFormat.SRT)); + sourceClip(), "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_LF_1, SttFormat.SRT)); SpeechToTextResponse.Srt srt = assertInstanceOf(SpeechToTextResponse.Srt.class, response, "format=srt must produce an Srt variant"); @@ -99,11 +113,8 @@ void transcribe_srtVariant(FanarJsonCodec codec) { @DisplayName("§M.7c transcribe (format=json, long-form model) returns Json variant with segments") void transcribe_jsonVariant(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { - byte[] wav = client.audio().speech(new TextToSpeechRequest( - TtsModel.FANAR_AURA_TTS_2, ARABIC_PROMPT, Voice.HUDA, TtsResponseFormat.WAV, null)); - SpeechToTextResponse response = client.audio().transcribe(new TranscriptionRequest( - wav, "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_LF_1, SttFormat.JSON)); + sourceClip(), "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_LF_1, SttFormat.JSON)); SpeechToTextResponse.Json json = assertInstanceOf(SpeechToTextResponse.Json.class, response, "format=json must produce a Json variant"); @@ -118,11 +129,8 @@ void transcribe_jsonVariant(FanarJsonCodec codec) { @DisplayName("§M.7c transcribe with default format → Text variant (server default)") void transcribe_defaultFormat(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { - byte[] wav = client.audio().speech(new TextToSpeechRequest( - TtsModel.FANAR_AURA_TTS_2, ARABIC_PROMPT, Voice.HUDA, TtsResponseFormat.WAV, null)); - SpeechToTextResponse response = client.audio().transcribe(TranscriptionRequest.of( - wav, "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_1)); + sourceClip(), "input.wav", "audio/wav", SttModel.FANAR_AURA_STT_1)); assertInstanceOf(SpeechToTextResponse.Text.class, response, "server default for format is text"); diff --git a/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioVoicesTest.java b/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioVoicesTest.java index 25b0283..9b37925 100644 --- a/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioVoicesTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/audio/LiveAudioVoicesTest.java @@ -13,6 +13,7 @@ import qa.fanar.core.FanarClient; import qa.fanar.core.audio.AudioClient; +import qa.fanar.core.audio.AvailableVoice; import qa.fanar.core.audio.CreateVoiceRequest; import qa.fanar.core.audio.VoiceResponse; import qa.fanar.core.spi.FanarJsonCodec; @@ -53,11 +54,17 @@ static Stream codecs() { @ParameterizedTest(name = "[{0}]") @MethodSource("codecs") - @DisplayName("§M.7a listVoices returns the (possibly empty) personalized voice list") + @DisplayName("§M.7a listVoices returns rich voice objects, always including the built-in public voices") void listVoices(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { VoiceResponse r = client.audio().listVoices(); - assertNotNull(r.voices(), "voices list must be present (may be empty)"); + assertNotNull(r.voices(), "voices list must be present"); + // Per the 2026-08 spec the listing always includes the built-in public voices. + assertFalse(r.voices().isEmpty(), "built-in public voices must always be listed"); + for (AvailableVoice v : r.voices()) { + assertNotNull(v.name(), "every listed voice carries its name"); + assertNotNull(v.type(), "every listed voice carries its public/personal type"); + } } } @@ -74,7 +81,7 @@ void createVoice(FanarJsonCodec codec) { try { VoiceResponse afterCreate = audio.listVoices(); - assertTrue(afterCreate.voices().contains(voiceName), + assertTrue(containsName(afterCreate, voiceName), "voice " + voiceName + " not in list after create: " + afterCreate.voices()); } finally { @@ -101,9 +108,13 @@ void deleteVoice(FanarJsonCodec codec) { // Assert: voice is no longer in the list. VoiceResponse afterDelete = audio.listVoices(); - assertFalse(afterDelete.voices().contains(voiceName), + assertFalse(containsName(afterDelete, voiceName), "voice " + voiceName + " still present after delete: " + afterDelete.voices()); } } + + private static boolean containsName(VoiceResponse response, String voiceName) { + return response.voices().stream().map(AvailableVoice::name).anyMatch(voiceName::equals); + } } diff --git a/e2e/src/test/java/qa/fanar/e2e/chat/LiveChatCompletionsTest.java b/e2e/src/test/java/qa/fanar/e2e/chat/LiveChatCompletionsTest.java index cb59f2b..0ab1a32 100644 --- a/e2e/src/test/java/qa/fanar/e2e/chat/LiveChatCompletionsTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/chat/LiveChatCompletionsTest.java @@ -143,6 +143,35 @@ void conversation_sadiqWithBookName(FanarJsonCodec codec) { } } + @ParameterizedTest(name = "[{0}]") + @MethodSource("codecs") + @DisplayName("§2.5 Sadiq with a custom persona lands on the wire and replies") + void conversation_sadiqWithPersona(FanarJsonCodec codec) { + try (FanarClient client = liveClient(codec)) { + ChatResponse r = client.chat().send(Probes.sadiqWithPersona()); + assertNotNull(r.id(), "response id must be present"); + assertNotNull(textOf(r), "persona-flavoured Sadiq must still return text"); + } + } + + /** + * {@code Fanar-Sadiq-2} requires additional authorization (spec-documented). Observed + * 2026-08-06: the model gate answers HTTP 422 {@code unprocessable} / + * "Model not authorized" — not 403 — so an un-upgraded key surfaces + * {@code FanarUnprocessableException} and fails loudly. That is the desired diagnostic + * signal, not a flake; the request itself (typed madhab wire format) is accepted up to the + * authorization check. See {@link Probes#sadiq2WithMadhab()}. + */ + @ParameterizedTest(name = "[{0}]") + @MethodSource("codecs") + @DisplayName("§2.6 Sadiq-2 with a typed madhab filter (gated — 422 'Model not authorized' until key upgraded)") + void conversation_sadiq2WithMadhab(FanarJsonCodec codec) { + try (FanarClient client = liveClient(codec)) { + ChatResponse r = client.chat().send(Probes.sadiq2WithMadhab()); + assertNotNull(r.id(), "response id must be present"); + } + } + // ===================================================================================== // §3 — Sampling determinism. // ===================================================================================== diff --git a/e2e/src/test/java/qa/fanar/e2e/images/LiveImagesTest.java b/e2e/src/test/java/qa/fanar/e2e/images/LiveImagesTest.java index a73e982..8d9a068 100644 --- a/e2e/src/test/java/qa/fanar/e2e/images/LiveImagesTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/images/LiveImagesTest.java @@ -69,6 +69,9 @@ void generate_returnsBase64Image(FanarJsonCodec codec) { ImageGenerationItem item = r.data().getFirst(); assertNotNull(item.b64Json(), "b64Json must be present"); assertFalse(item.b64Json().isBlank(), "b64Json must not be blank"); + // Spec (2026-08): revised + revised_prompt are required response fields; the + // server default revise=true means the revised prompt should be present either way. + assertNotNull(item.revisedPrompt(), "revised_prompt must be present"); // Soft validation: the body should round-trip through the JDK Base64 decoder. byte[] decoded = Base64.getDecoder().decode(item.b64Json()); diff --git a/e2e/src/test/java/qa/fanar/e2e/models/LiveModelsTest.java b/e2e/src/test/java/qa/fanar/e2e/models/LiveModelsTest.java index 7ece41e..aa62475 100644 --- a/e2e/src/test/java/qa/fanar/e2e/models/LiveModelsTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/models/LiveModelsTest.java @@ -30,14 +30,29 @@ * Live battle-test of {@code GET /v1/models} via {@link FanarClient#models()}, parameterized * over both codec adapters. * - *

    Asserts that every {@link ChatModel} constant the SDK ships still appears in the live - * response. If Fanar drops a known model, this test fires before the regression hits any user. - * Skipped when {@code FANAR_API_KEY} is not set.

    + *

    Asserts that every publicly visible {@link ChatModel} constant the SDK ships + * still appears in the live response. If Fanar drops a known public model, this test fires + * before the regression hits any user.

    + * + *

    The listing is visibility-scoped, not the universe of callable models: models gated at + * the model level are omitted for keys without access (observed 2026-08-06 — {@code + * Fanar-Sadiq-2}, {@code Fanar-Diwan}, {@code Fanar-Sadiq-TTS-1}, and even the callable + * {@code Fanar-Guard-2} were absent while spec-listed). {@code Fanar-C-2-27B} stays listed + * because its gating is feature-level ({@code enable_thinking}), not model-level. Gated + * entries in {@link #MODEL_GATED} are therefore allowed — not required — to appear.

    + * + *

    Skipped when {@code FANAR_API_KEY} is not set.

    */ @Tag("live") @EnabledIfEnvironmentVariable(named = "FANAR_API_KEY", matches = ".+") class LiveModelsTest { + /** + * Chat models the spec documents as requiring model-level additional authorization — + * legitimately absent from the listing until the key is upgraded. + */ + private static final Set MODEL_GATED = Set.of(ChatModel.FANAR_SADIQ_2); + static Stream codecs() { return Stream.of( Arguments.of(Named.of("jackson2", new Jackson2FanarJsonCodec())), @@ -46,7 +61,7 @@ static Stream codecs() { @ParameterizedTest(name = "[{0}]") @MethodSource("codecs") - @DisplayName("§M.1 list returns every ChatModel.KNOWN.wireValue() the SDK ships") + @DisplayName("§M.1 list returns every non-gated ChatModel.KNOWN.wireValue() the SDK ships") void list_returnsAllKnownChatModels(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { ModelsResponse r = client.models().list(); @@ -58,10 +73,14 @@ void list_returnsAllKnownChatModels(FanarJsonCodec codec) { .collect(Collectors.toSet()); for (ChatModel known : ChatModel.KNOWN) { + if (MODEL_GATED.contains(known)) { + continue; // may appear once the key is authorized; absence is not drift + } assertTrue(wireIds.contains(known.wireValue()), "spec drift: SDK ships ChatModel.KNOWN entry \"" + known.wireValue() + "\" but it isn't in /v1/models response. Either Fanar dropped " - + "the model or the SDK's catalogue is stale."); + + "the model, the SDK's catalogue is stale, or the model became " + + "gated (then move it to MODEL_GATED with a dated note)."); } } } diff --git a/e2e/src/test/java/qa/fanar/e2e/poems/LivePoemsTest.java b/e2e/src/test/java/qa/fanar/e2e/poems/LivePoemsTest.java index 91667ac..3d208f6 100644 --- a/e2e/src/test/java/qa/fanar/e2e/poems/LivePoemsTest.java +++ b/e2e/src/test/java/qa/fanar/e2e/poems/LivePoemsTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import qa.fanar.core.FanarClient; +import qa.fanar.core.FanarUnprocessableException; import qa.fanar.core.poems.PoemGenerationRequest; import qa.fanar.core.poems.PoemGenerationResponse; import qa.fanar.core.poems.PoemModel; @@ -16,6 +17,7 @@ import qa.fanar.json.jackson2.Jackson2FanarJsonCodec; import qa.fanar.json.jackson3.Jackson3FanarJsonCodec; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -26,15 +28,30 @@ * Live battle-test of {@code POST /v1/poems/generations} via {@link FanarClient#poems()}, * parameterized over both codec adapters. * - *

    Per the Fanar spec this endpoint requires additional authorization. {@code Fanar-Diwan} - * also did not appear in the live {@code /v1/models} listing for our SDK key as of 2026-04-25, - * so this test may surface a {@code FanarAuthorizationException} (HTTP 403). Share the wire - * log when that happens. Skipped when {@code FANAR_API_KEY} is not set.

    + *

    Diwan history for our SDK key. As of 2026-04-25 the model was gated + * (absent from {@code /v1/models}, calls surfacing 403/504). As of 2026-08-06 generation + * works — but the endpoint is nondeterministic: within one run, identical requests + * alternated between 200 (a full poem) and 422 {@code unprocessable} / "No suitable verses + * found for the given prompt" (observed 2 of 4 calls). Diwan composes from a verse corpus and + * sometimes reports a retrieval miss for a prompt it served moments earlier.

    + * + *

    Each test therefore retries only {@link FanarUnprocessableException} up to + * {@link #VERSE_MATCH_ATTEMPTS} times. This is a deliberate, narrowly-scoped exception to the + * fail-loudly rule: a documented nondeterministic semantic outcome is retried, while + * authorization / timeout / transport errors still fail on the first occurrence, and a + * persistent verse-miss still fails with the typed exception preserved. Share the wire log + * when that happens. Skipped when {@code FANAR_API_KEY} is not set.

    */ @Tag("live") @EnabledIfEnvironmentVariable(named = "FANAR_API_KEY", matches = ".+") class LivePoemsTest { + private static final PoemGenerationRequest SEA_POEM = PoemGenerationRequest.of( + PoemModel.FANAR_DIWAN, "Write a poem about the sea"); + + /** Verse-miss retries per test; Diwan is 50/min so the extra budget is negligible. */ + private static final int VERSE_MATCH_ATTEMPTS = 3; + static Stream codecs() { return Stream.of( Arguments.of(Named.of("jackson2", new Jackson2FanarJsonCodec())), @@ -43,12 +60,10 @@ static Stream codecs() { @ParameterizedTest(name = "[{0}]") @MethodSource("codecs") - @DisplayName("§M.5 generate returns non-empty poem text (or surfaces a typed access error)") + @DisplayName("§M.5 generate returns non-empty poem text (verse-miss 422 retried, see Javadoc)") void generate_returnsNonEmptyPoem(FanarJsonCodec codec) { try (FanarClient client = TestClients.liveWithLogging(codec)) { - PoemGenerationResponse r = client.poems().generate( - PoemGenerationRequest.of(PoemModel.FANAR_DIWAN, - "Write a poem about the sea")); + PoemGenerationResponse r = generateRetryingVerseMisses(client); assertNotNull(r.id(), "response id must be present"); assertNotNull(r.poem(), "poem text must be present"); @@ -61,13 +76,40 @@ void generate_returnsNonEmptyPoem(FanarJsonCodec codec) { @DisplayName("§M.5 generateAsync().get() completes against live infra with non-blank poem") void generate_asyncCompletesAgainstLiveInfra(FanarJsonCodec codec) throws Exception { try (FanarClient client = TestClients.liveWithLogging(codec)) { - PoemGenerationResponse r = client.poems().generateAsync( - PoemGenerationRequest.of(PoemModel.FANAR_DIWAN, - "Write a poem about the sea")) - .get(60, TimeUnit.SECONDS); + PoemGenerationResponse r = generateAsyncRetryingVerseMisses(client); + assertNotNull(r.id(), "response id must be present"); assertNotNull(r.poem(), "poem text must be present"); assertFalse(r.poem().isBlank(), "poem text must not be blank"); } } + + private static PoemGenerationResponse generateRetryingVerseMisses(FanarClient client) { + FanarUnprocessableException lastMiss = null; + for (int attempt = 1; attempt <= VERSE_MATCH_ATTEMPTS; attempt++) { + try { + return client.poems().generate(SEA_POEM); + } catch (FanarUnprocessableException e) { + lastMiss = e; // nondeterministic verse miss — retry; anything else propagates + } + } + throw lastMiss; + } + + private static PoemGenerationResponse generateAsyncRetryingVerseMisses(FanarClient client) + throws Exception { + FanarUnprocessableException lastMiss = null; + for (int attempt = 1; attempt <= VERSE_MATCH_ATTEMPTS; attempt++) { + try { + return client.poems().generateAsync(SEA_POEM).get(60, TimeUnit.SECONDS); + } catch (ExecutionException e) { + if (e.getCause() instanceof FanarUnprocessableException miss) { + lastMiss = miss; // nondeterministic verse miss — retry + } else { + throw e; + } + } + } + throw lastMiss; + } } diff --git a/json-jackson2/src/main/java/qa/fanar/json/jackson2/WireValueModule.java b/json-jackson2/src/main/java/qa/fanar/json/jackson2/WireValueModule.java index 906aa59..4344ad7 100644 --- a/json-jackson2/src/main/java/qa/fanar/json/jackson2/WireValueModule.java +++ b/json-jackson2/src/main/java/qa/fanar/json/jackson2/WireValueModule.java @@ -17,9 +17,11 @@ import qa.fanar.core.audio.TtsModel; import qa.fanar.core.audio.TtsResponseFormat; import qa.fanar.core.audio.Voice; +import qa.fanar.core.audio.VoiceType; import qa.fanar.core.chat.ChatModel; import qa.fanar.core.chat.FinishReason; import qa.fanar.core.chat.ImageDetail; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.Source; import qa.fanar.core.images.ImageModel; import qa.fanar.core.moderations.ModerationModel; @@ -50,6 +52,7 @@ static SimpleModule create() { register(module, ChatModel.class, ChatModel::wireValue, ChatModel::of); register(module, FinishReason.class, FinishReason::wireValue, FinishReason::of); register(module, ImageDetail.class, ImageDetail::wireValue, ImageDetail::of); + register(module, Madhab.class, Madhab::wireValue, Madhab::of); register(module, Source.class, Source::wireValue, Source::of); register(module, ModerationModel.class, ModerationModel::wireValue, ModerationModel::of); register(module, TranslationModel.class, TranslationModel::wireValue, TranslationModel::of); @@ -62,6 +65,7 @@ static SimpleModule create() { register(module, TtsResponseFormat.class, TtsResponseFormat::wireValue, TtsResponseFormat::of); register(module, QuranReciter.class, QuranReciter::wireValue, QuranReciter::of); register(module, Voice.class, Voice::wireValue, Voice::of); + register(module, VoiceType.class, VoiceType::wireValue, VoiceType::of); register(module, SttModel.class, SttModel::wireValue, SttModel::of); register(module, SttFormat.class, SttFormat::wireValue, SttFormat::of); return module; diff --git a/json-jackson2/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson2/reachability-metadata.json b/json-jackson2/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson2/reachability-metadata.json index 10980b0..dea3867 100644 --- a/json-jackson2/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson2/reachability-metadata.json +++ b/json-jackson2/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson2/reachability-metadata.json @@ -27,6 +27,10 @@ { "type": "qa.fanar.json.jackson2.ProgressChunkDeserializer", "allDeclaredConstructors": true + }, + { + "type": "qa.fanar.json.jackson2.SpeechToTextResponseDeserializer", + "allDeclaredConstructors": true } ] } diff --git a/json-jackson2/src/test/java/qa/fanar/json/jackson2/ChatRequestKnobsTest.java b/json-jackson2/src/test/java/qa/fanar/json/jackson2/ChatRequestKnobsTest.java index 1e58242..90c7923 100644 --- a/json-jackson2/src/test/java/qa/fanar/json/jackson2/ChatRequestKnobsTest.java +++ b/json-jackson2/src/test/java/qa/fanar/json/jackson2/ChatRequestKnobsTest.java @@ -11,6 +11,7 @@ import qa.fanar.core.chat.AssistantMessage; import qa.fanar.core.chat.BookName; import qa.fanar.core.chat.ChatModel; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.ChatRequest; import qa.fanar.core.chat.Source; import qa.fanar.core.chat.SystemMessage; @@ -125,6 +126,22 @@ void sadiqIslamicRagKnobsSerializeWithSourceWireValues() throws IOException { () -> assertTrue(json.contains("\"filter_sources\":[\"" + Source.TAFSIR.wireValue() + "\"]"), json)); } + + @Test + void sadiq2PersonaAndMadhabKnobsSerialize() throws IOException { + ChatRequest req = base() + .model(ChatModel.FANAR_SADIQ_2) + .persona("Warm, patient teacher") + .madhab(List.of(Madhab.HANAFI, Madhab.ALL)) + .build(); + String json = encode(req); + assertAll( + () -> assertTrue(json.contains("\"model\":\"" + ChatModel.FANAR_SADIQ_2.wireValue() + "\""), json), + () -> assertTrue(json.contains("\"persona\":\"Warm, patient teacher\""), json), + () -> assertTrue(json.contains("\"madhab\":[\"" + Madhab.HANAFI.wireValue() + + "\",\"" + Madhab.ALL.wireValue() + "\"]"), json)); + } + @Test void enableThinkingAndModelWireValueSerialize() throws IOException { ChatRequest req = base() @@ -169,6 +186,8 @@ void unsetOptionalFieldsAreOmitted() throws IOException { () -> assertFalse(json.contains("logit_bias"), json), () -> assertFalse(json.contains("restrict_to_islamic"), json), () -> assertFalse(json.contains("enable_thinking"), json), + () -> assertFalse(json.contains("persona"), json), + () -> assertFalse(json.contains("madhab"), json), () -> assertFalse(json.contains("stop"), json)); } diff --git a/json-jackson3/src/main/java/qa/fanar/json/jackson3/WireValueModule.java b/json-jackson3/src/main/java/qa/fanar/json/jackson3/WireValueModule.java index 9b6624d..8d98be2 100644 --- a/json-jackson3/src/main/java/qa/fanar/json/jackson3/WireValueModule.java +++ b/json-jackson3/src/main/java/qa/fanar/json/jackson3/WireValueModule.java @@ -16,9 +16,11 @@ import qa.fanar.core.audio.TtsModel; import qa.fanar.core.audio.TtsResponseFormat; import qa.fanar.core.audio.Voice; +import qa.fanar.core.audio.VoiceType; import qa.fanar.core.chat.ChatModel; import qa.fanar.core.chat.FinishReason; import qa.fanar.core.chat.ImageDetail; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.Source; import qa.fanar.core.images.ImageModel; import qa.fanar.core.moderations.ModerationModel; @@ -49,6 +51,7 @@ static SimpleModule create() { register(module, ChatModel.class, ChatModel::wireValue, ChatModel::of); register(module, FinishReason.class, FinishReason::wireValue, FinishReason::of); register(module, ImageDetail.class, ImageDetail::wireValue, ImageDetail::of); + register(module, Madhab.class, Madhab::wireValue, Madhab::of); register(module, Source.class, Source::wireValue, Source::of); register(module, ModerationModel.class, ModerationModel::wireValue, ModerationModel::of); register(module, TranslationModel.class, TranslationModel::wireValue, TranslationModel::of); @@ -61,6 +64,7 @@ static SimpleModule create() { register(module, TtsResponseFormat.class, TtsResponseFormat::wireValue, TtsResponseFormat::of); register(module, QuranReciter.class, QuranReciter::wireValue, QuranReciter::of); register(module, Voice.class, Voice::wireValue, Voice::of); + register(module, VoiceType.class, VoiceType::wireValue, VoiceType::of); register(module, SttModel.class, SttModel::wireValue, SttModel::of); register(module, SttFormat.class, SttFormat::wireValue, SttFormat::of); return module; diff --git a/json-jackson3/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson3/reachability-metadata.json b/json-jackson3/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson3/reachability-metadata.json index 7669131..abb8f55 100644 --- a/json-jackson3/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson3/reachability-metadata.json +++ b/json-jackson3/src/main/resources/META-INF/native-image/qa.fanar/fanar-json-jackson3/reachability-metadata.json @@ -27,6 +27,10 @@ { "type": "qa.fanar.json.jackson3.ProgressChunkDeserializer", "allDeclaredConstructors": true + }, + { + "type": "qa.fanar.json.jackson3.SpeechToTextResponseDeserializer", + "allDeclaredConstructors": true } ] } diff --git a/json-jackson3/src/test/java/qa/fanar/json/jackson3/ChatRequestKnobsTest.java b/json-jackson3/src/test/java/qa/fanar/json/jackson3/ChatRequestKnobsTest.java index 1cbc488..1424592 100644 --- a/json-jackson3/src/test/java/qa/fanar/json/jackson3/ChatRequestKnobsTest.java +++ b/json-jackson3/src/test/java/qa/fanar/json/jackson3/ChatRequestKnobsTest.java @@ -11,6 +11,7 @@ import qa.fanar.core.chat.AssistantMessage; import qa.fanar.core.chat.BookName; import qa.fanar.core.chat.ChatModel; +import qa.fanar.core.chat.Madhab; import qa.fanar.core.chat.ChatRequest; import qa.fanar.core.chat.Source; import qa.fanar.core.chat.SystemMessage; @@ -125,6 +126,22 @@ void sadiqIslamicRagKnobsSerializeWithSourceWireValues() throws IOException { () -> assertTrue(json.contains("\"filter_sources\":[\"" + Source.TAFSIR.wireValue() + "\"]"), json)); } + + @Test + void sadiq2PersonaAndMadhabKnobsSerialize() throws IOException { + ChatRequest req = base() + .model(ChatModel.FANAR_SADIQ_2) + .persona("Warm, patient teacher") + .madhab(List.of(Madhab.HANAFI, Madhab.ALL)) + .build(); + String json = encode(req); + assertAll( + () -> assertTrue(json.contains("\"model\":\"" + ChatModel.FANAR_SADIQ_2.wireValue() + "\""), json), + () -> assertTrue(json.contains("\"persona\":\"Warm, patient teacher\""), json), + () -> assertTrue(json.contains("\"madhab\":[\"" + Madhab.HANAFI.wireValue() + + "\",\"" + Madhab.ALL.wireValue() + "\"]"), json)); + } + @Test void enableThinkingAndModelWireValueSerialize() throws IOException { ChatRequest req = base() @@ -172,6 +189,8 @@ void unsetOptionalFieldsAreOmitted() throws IOException { () -> assertFalse(json.contains("logit_bias"), json), () -> assertFalse(json.contains("restrict_to_islamic"), json), () -> assertFalse(json.contains("enable_thinking"), json), + () -> assertFalse(json.contains("persona"), json), + () -> assertFalse(json.contains("madhab"), json), () -> assertFalse(json.contains("stop"), json)); } diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatModel.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatModel.java index b2641ae..90c1ba7 100644 --- a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatModel.java +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatModel.java @@ -39,6 +39,10 @@ * {@code Flow.Publisher} to Reactor {@code Flux} with one * {@code ChatResponse} per Fanar token chunk — Spring AI's {@code ChatClient} accumulates them.

    * + *

    Portable {@link ChatOptions} map to their Fanar equivalents; pass a + * {@link FanarChatOptions} to additionally reach the Fanar-only knobs (persona, madhab, + * thinking mode, Islamic-RAG scoping, vLLM sampling) — see ADR-024.

    + * *

    What the adapter does not do:

    *
      *
    • Tool calls. Fanar's API rejects user-supplied tools (it returns server-internal @@ -142,6 +146,85 @@ private static void applyOptions(ChatRequest.Builder builder, ChatOptions option if (options.getStopSequences() != null && !options.getStopSequences().isEmpty()) { builder.stop(options.getStopSequences()); } + if (options instanceof FanarChatOptions fanarOptions) { + applyFanarOptions(builder, fanarOptions); + } + } + + /** Fanar extras beyond the portable {@link ChatOptions} surface (ADR-024). */ + private static void applyFanarOptions(ChatRequest.Builder builder, FanarChatOptions o) { + if (o.getPersona() != null) { + builder.persona(o.getPersona()); + } + if (o.getMadhab() != null) { + builder.madhab(o.getMadhab()); + } + if (o.getEnableThinking() != null) { + builder.enableThinking(o.getEnableThinking()); + } + if (o.getRestrictToIslamic() != null) { + builder.restrictToIslamic(o.getRestrictToIslamic()); + } + if (o.getBookNames() != null) { + builder.bookNames(o.getBookNames()); + } + if (o.getPreferredSources() != null) { + builder.preferredSources(o.getPreferredSources()); + } + if (o.getExcludeSources() != null) { + builder.excludeSources(o.getExcludeSources()); + } + if (o.getFilterSources() != null) { + builder.filterSources(o.getFilterSources()); + } + if (o.getLogitBias() != null) { + builder.logitBias(o.getLogitBias()); + } + if (o.getLogprobs() != null) { + builder.logprobs(o.getLogprobs()); + } + if (o.getTopLogprobs() != null) { + builder.topLogprobs(o.getTopLogprobs()); + } + if (o.getN() != null) { + builder.n(o.getN()); + } + if (o.getMinP() != null) { + builder.minP(o.getMinP()); + } + if (o.getRepetitionPenalty() != null) { + builder.repetitionPenalty(o.getRepetitionPenalty()); + } + if (o.getBestOf() != null) { + builder.bestOf(o.getBestOf()); + } + if (o.getLengthPenalty() != null) { + builder.lengthPenalty(o.getLengthPenalty()); + } + if (o.getEarlyStopping() != null) { + builder.earlyStopping(o.getEarlyStopping()); + } + if (o.getStopTokenIds() != null) { + builder.stopTokenIds(o.getStopTokenIds()); + } + if (o.getIgnoreEos() != null) { + builder.ignoreEos(o.getIgnoreEos()); + } + if (o.getMinTokens() != null) { + builder.minTokens(o.getMinTokens()); + } + if (o.getSkipSpecialTokens() != null) { + builder.skipSpecialTokens(o.getSkipSpecialTokens()); + } + if (o.getSpacesBetweenSpecialTokens() != null) { + builder.spacesBetweenSpecialTokens(o.getSpacesBetweenSpecialTokens()); + } + if (o.getTruncatePromptTokens() != null) { + builder.truncatePromptTokens(o.getTruncatePromptTokens()); + } + if (o.getPromptLogprobs() != null) { + builder.promptLogprobs(o.getPromptLogprobs()); + } } // --------------------------------------------------------------------------------------- diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatOptions.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatOptions.java new file mode 100644 index 0000000..dc7d969 --- /dev/null +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarChatOptions.java @@ -0,0 +1,400 @@ +package qa.fanar.spring.ai; + +import java.util.List; +import java.util.Map; + +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.DefaultChatOptionsBuilder; + +import qa.fanar.core.chat.BookName; +import qa.fanar.core.chat.Madhab; +import qa.fanar.core.chat.Source; + +/** + * Fanar-specific {@link ChatOptions}: the standard portable knobs plus every Fanar chat + * parameter that portable options cannot carry — Islamic-RAG scoping, persona, madhab, + * thinking mode, and the vLLM-flavoured sampling knobs. + * + *

      Pass an instance as the prompt's options ({@code ChatClient.prompt().options(...)} or + * {@code new Prompt(messages, options)}); {@code FanarChatModel} maps the portable getters like + * any {@link ChatOptions} and additionally applies the Fanar extras. Any other + * {@link ChatOptions} implementation keeps working — the extras are then simply unset + * (ADR-024).

      + * + *

      {@link Builder} extends Spring AI's {@link DefaultChatOptionsBuilder}, so + * {@link #mutate()} round-trips all fields — including the Fanar extras — through the + * {@code ChatClient} pipeline (which rebuilds request options via {@code mutate()}), and + * {@link Builder#combineWith(ChatOptions.Builder)} merges extras when combining two Fanar + * builders (non-null values from the other builder win; the portable fields follow Spring AI's + * own merge rules).

      + * + *

      Instances are immutable; collections are defensively copied at build time. Field semantics + * and validation mirror {@code qa.fanar.core.chat.ChatRequest} — validation happens when the + * request is built, not here.

      + * + * @author Oussama Mahjoub + */ +public final class FanarChatOptions implements ChatOptions { + + // --- portable (ChatOptions) --- + private final String model; + private final Double temperature; + private final Double topP; + private final Integer topK; + private final Integer maxTokens; + private final Double frequencyPenalty; + private final Double presencePenalty; + private final List stopSequences; + + // --- Fanar extras --- + private final String persona; + private final List madhab; + private final Boolean enableThinking; + private final Boolean restrictToIslamic; + private final List bookNames; + private final List preferredSources; + private final List excludeSources; + private final List filterSources; + private final Map logitBias; + private final Boolean logprobs; + private final Integer topLogprobs; + private final Integer n; + private final Double minP; + private final Double repetitionPenalty; + private final Integer bestOf; + private final Double lengthPenalty; + private final Boolean earlyStopping; + private final List stopTokenIds; + private final Boolean ignoreEos; + private final Integer minTokens; + private final Boolean skipSpecialTokens; + private final Boolean spacesBetweenSpecialTokens; + private final Integer truncatePromptTokens; + private final Integer promptLogprobs; + + private FanarChatOptions( + Builder b, + String model, Double temperature, Double topP, Integer topK, Integer maxTokens, + Double frequencyPenalty, Double presencePenalty, List stopSequences) { + this.model = model; + this.temperature = temperature; + this.topP = topP; + this.topK = topK; + this.maxTokens = maxTokens; + this.frequencyPenalty = frequencyPenalty; + this.presencePenalty = presencePenalty; + this.stopSequences = stopSequences == null ? null : List.copyOf(stopSequences); + this.persona = b.persona; + this.madhab = b.madhab == null ? null : List.copyOf(b.madhab); + this.enableThinking = b.enableThinking; + this.restrictToIslamic = b.restrictToIslamic; + this.bookNames = b.bookNames == null ? null : List.copyOf(b.bookNames); + this.preferredSources = b.preferredSources == null ? null : List.copyOf(b.preferredSources); + this.excludeSources = b.excludeSources == null ? null : List.copyOf(b.excludeSources); + this.filterSources = b.filterSources == null ? null : List.copyOf(b.filterSources); + this.logitBias = b.logitBias == null ? null : Map.copyOf(b.logitBias); + this.logprobs = b.logprobs; + this.topLogprobs = b.topLogprobs; + this.n = b.n; + this.minP = b.minP; + this.repetitionPenalty = b.repetitionPenalty; + this.bestOf = b.bestOf; + this.lengthPenalty = b.lengthPenalty; + this.earlyStopping = b.earlyStopping; + this.stopTokenIds = b.stopTokenIds == null ? null : List.copyOf(b.stopTokenIds); + this.ignoreEos = b.ignoreEos; + this.minTokens = b.minTokens; + this.skipSpecialTokens = b.skipSpecialTokens; + this.spacesBetweenSpecialTokens = b.spacesBetweenSpecialTokens; + this.truncatePromptTokens = b.truncatePromptTokens; + this.promptLogprobs = b.promptLogprobs; + } + + /** Start a fresh builder. */ + public static Builder builder() { + return new Builder(); + } + + // --- portable getters (ChatOptions) --- + + @Override public String getModel() { return model; } + @Override public Double getTemperature() { return temperature; } + @Override public Double getTopP() { return topP; } + @Override public Integer getTopK() { return topK; } + @Override public Integer getMaxTokens() { return maxTokens; } + @Override public Double getFrequencyPenalty() { return frequencyPenalty; } + @Override public Double getPresencePenalty() { return presencePenalty; } + @Override public List getStopSequences() { return stopSequences; } + + /** Full-fidelity mutation: the returned builder carries the Fanar extras too. */ + @Override + public ChatOptions.Builder mutate() { + return toBuilder(); + } + + /** A fresh {@link Builder} pre-populated with every field of this instance. */ + public Builder toBuilder() { + Builder b = new Builder() + .persona(persona) + .madhab(madhab) + .enableThinking(enableThinking) + .restrictToIslamic(restrictToIslamic) + .bookNames(bookNames) + .preferredSources(preferredSources) + .excludeSources(excludeSources) + .filterSources(filterSources) + .logitBias(logitBias) + .logprobs(logprobs) + .topLogprobs(topLogprobs) + .n(n) + .minP(minP) + .repetitionPenalty(repetitionPenalty) + .bestOf(bestOf) + .lengthPenalty(lengthPenalty) + .earlyStopping(earlyStopping) + .stopTokenIds(stopTokenIds) + .ignoreEos(ignoreEos) + .minTokens(minTokens) + .skipSpecialTokens(skipSpecialTokens) + .spacesBetweenSpecialTokens(spacesBetweenSpecialTokens) + .truncatePromptTokens(truncatePromptTokens) + .promptLogprobs(promptLogprobs); + return b.model(model) + .temperature(temperature) + .topP(topP) + .topK(topK) + .maxTokens(maxTokens) + .frequencyPenalty(frequencyPenalty) + .presencePenalty(presencePenalty) + .stopSequences(stopSequences); + } + + // --- Fanar extras --- + + /** Custom assistant persona ({@code Fanar-Sadiq} only), or {@code null}. */ + public String getPersona() { return persona; } + + /** Madhab filter for {@code Fanar-Sadiq-2}, or {@code null}. */ + public List getMadhab() { return madhab; } + + /** Thinking-mode flag ({@code Fanar-C-2-27B}), or {@code null}. */ + public Boolean getEnableThinking() { return enableThinking; } + + /** Server-side non-Islamic prompt rejection ({@code Fanar-Sadiq}), or {@code null}. */ + public Boolean getRestrictToIslamic() { return restrictToIslamic; } + + /** Islamic-RAG retrieval scope: book filter, or {@code null}. */ + public List getBookNames() { return bookNames; } + + /** Islamic-RAG retrieval scope: preferred corpora, or {@code null}. */ + public List getPreferredSources() { return preferredSources; } + + /** Islamic-RAG retrieval scope: excluded corpora, or {@code null}. */ + public List getExcludeSources() { return excludeSources; } + + /** Islamic-RAG retrieval scope: hard corpus filter, or {@code null}. */ + public List getFilterSources() { return filterSources; } + + /** Token-id → bias map, or {@code null}. */ + public Map getLogitBias() { return logitBias; } + + /** Return log-probabilities, or {@code null}. */ + public Boolean getLogprobs() { return logprobs; } + + /** How many top log-probabilities per token, or {@code null}. */ + public Integer getTopLogprobs() { return topLogprobs; } + + /** Number of completions to generate, or {@code null}. */ + public Integer getN() { return n; } + + /** Min-p sampling, or {@code null}. */ + public Double getMinP() { return minP; } + + /** Repetition penalty, or {@code null}. */ + public Double getRepetitionPenalty() { return repetitionPenalty; } + + /** Beam-search candidate count, or {@code null}. */ + public Integer getBestOf() { return bestOf; } + + /** Beam-search length penalty, or {@code null}. */ + public Double getLengthPenalty() { return lengthPenalty; } + + /** Beam-search early stopping, or {@code null}. */ + public Boolean getEarlyStopping() { return earlyStopping; } + + /** Stop token ids, or {@code null}. */ + public List getStopTokenIds() { return stopTokenIds; } + + /** Ignore end-of-sequence token, or {@code null}. */ + public Boolean getIgnoreEos() { return ignoreEos; } + + /** Minimum tokens to generate, or {@code null}. */ + public Integer getMinTokens() { return minTokens; } + + /** Skip special tokens in output, or {@code null}. */ + public Boolean getSkipSpecialTokens() { return skipSpecialTokens; } + + /** Spaces between special tokens, or {@code null}. */ + public Boolean getSpacesBetweenSpecialTokens() { return spacesBetweenSpecialTokens; } + + /** Prompt truncation limit, or {@code null}. */ + public Integer getTruncatePromptTokens() { return truncatePromptTokens; } + + /** Prompt log-probabilities, or {@code null}. */ + public Integer getPromptLogprobs() { return promptLogprobs; } + + /** + * Fluent builder; every field defaults to {@code null} ("use the server default"). + * + *

      Extends {@link DefaultChatOptionsBuilder} so Spring AI treats it as a first-class + * {@link ChatOptions.Builder}: the portable setters, {@code clone()}, and the portable half + * of {@code combineWith(...)} are inherited. The override below additionally merges the + * Fanar extras when the other builder is also a {@code FanarChatOptions.Builder} — + * non-null scalar and collection values from {@code other} replace this builder's values + * (collections replace rather than concatenate: they are filters, and appending two + * filters is not a meaningful union).

      + */ + public static final class Builder extends DefaultChatOptionsBuilder { + + private String persona; + private List madhab; + private Boolean enableThinking; + private Boolean restrictToIslamic; + private List bookNames; + private List preferredSources; + private List excludeSources; + private List filterSources; + private Map logitBias; + private Boolean logprobs; + private Integer topLogprobs; + private Integer n; + private Double minP; + private Double repetitionPenalty; + private Integer bestOf; + private Double lengthPenalty; + private Boolean earlyStopping; + private List stopTokenIds; + private Boolean ignoreEos; + private Integer minTokens; + private Boolean skipSpecialTokens; + private Boolean spacesBetweenSpecialTokens; + private Integer truncatePromptTokens; + private Integer promptLogprobs; + + private Builder() { + // use FanarChatOptions.builder() + } + + public Builder persona(String persona) { this.persona = persona; return this; } + public Builder madhab(List madhab) { this.madhab = madhab; return this; } + public Builder enableThinking(Boolean enableThinking) { this.enableThinking = enableThinking; return this; } + public Builder restrictToIslamic(Boolean restrictToIslamic) { this.restrictToIslamic = restrictToIslamic; return this; } + public Builder bookNames(List bookNames) { this.bookNames = bookNames; return this; } + public Builder preferredSources(List preferredSources) { this.preferredSources = preferredSources; return this; } + public Builder excludeSources(List excludeSources) { this.excludeSources = excludeSources; return this; } + public Builder filterSources(List filterSources) { this.filterSources = filterSources; return this; } + public Builder logitBias(Map logitBias) { this.logitBias = logitBias; return this; } + public Builder logprobs(Boolean logprobs) { this.logprobs = logprobs; return this; } + public Builder topLogprobs(Integer topLogprobs) { this.topLogprobs = topLogprobs; return this; } + public Builder n(Integer n) { this.n = n; return this; } + public Builder minP(Double minP) { this.minP = minP; return this; } + public Builder repetitionPenalty(Double repetitionPenalty) { this.repetitionPenalty = repetitionPenalty; return this; } + public Builder bestOf(Integer bestOf) { this.bestOf = bestOf; return this; } + public Builder lengthPenalty(Double lengthPenalty) { this.lengthPenalty = lengthPenalty; return this; } + public Builder earlyStopping(Boolean earlyStopping) { this.earlyStopping = earlyStopping; return this; } + public Builder stopTokenIds(List stopTokenIds) { this.stopTokenIds = stopTokenIds; return this; } + public Builder ignoreEos(Boolean ignoreEos) { this.ignoreEos = ignoreEos; return this; } + public Builder minTokens(Integer minTokens) { this.minTokens = minTokens; return this; } + public Builder skipSpecialTokens(Boolean skipSpecialTokens) { this.skipSpecialTokens = skipSpecialTokens; return this; } + public Builder spacesBetweenSpecialTokens(Boolean spacesBetweenSpecialTokens) { this.spacesBetweenSpecialTokens = spacesBetweenSpecialTokens; return this; } + public Builder truncatePromptTokens(Integer truncatePromptTokens) { this.truncatePromptTokens = truncatePromptTokens; return this; } + public Builder promptLogprobs(Integer promptLogprobs) { this.promptLogprobs = promptLogprobs; return this; } + + @Override + public Builder combineWith(ChatOptions.Builder other) { + super.combineWith(other); + if (other instanceof Builder that) { + if (that.persona != null) { + this.persona = that.persona; + } + if (that.madhab != null) { + this.madhab = that.madhab; + } + if (that.enableThinking != null) { + this.enableThinking = that.enableThinking; + } + if (that.restrictToIslamic != null) { + this.restrictToIslamic = that.restrictToIslamic; + } + if (that.bookNames != null) { + this.bookNames = that.bookNames; + } + if (that.preferredSources != null) { + this.preferredSources = that.preferredSources; + } + if (that.excludeSources != null) { + this.excludeSources = that.excludeSources; + } + if (that.filterSources != null) { + this.filterSources = that.filterSources; + } + if (that.logitBias != null) { + this.logitBias = that.logitBias; + } + if (that.logprobs != null) { + this.logprobs = that.logprobs; + } + if (that.topLogprobs != null) { + this.topLogprobs = that.topLogprobs; + } + if (that.n != null) { + this.n = that.n; + } + if (that.minP != null) { + this.minP = that.minP; + } + if (that.repetitionPenalty != null) { + this.repetitionPenalty = that.repetitionPenalty; + } + if (that.bestOf != null) { + this.bestOf = that.bestOf; + } + if (that.lengthPenalty != null) { + this.lengthPenalty = that.lengthPenalty; + } + if (that.earlyStopping != null) { + this.earlyStopping = that.earlyStopping; + } + if (that.stopTokenIds != null) { + this.stopTokenIds = that.stopTokenIds; + } + if (that.ignoreEos != null) { + this.ignoreEos = that.ignoreEos; + } + if (that.minTokens != null) { + this.minTokens = that.minTokens; + } + if (that.skipSpecialTokens != null) { + this.skipSpecialTokens = that.skipSpecialTokens; + } + if (that.spacesBetweenSpecialTokens != null) { + this.spacesBetweenSpecialTokens = that.spacesBetweenSpecialTokens; + } + if (that.truncatePromptTokens != null) { + this.truncatePromptTokens = that.truncatePromptTokens; + } + if (that.promptLogprobs != null) { + this.promptLogprobs = that.promptLogprobs; + } + } + return self(); + } + + @Override + public FanarChatOptions build() { + return new FanarChatOptions(this, + this.model, this.temperature, this.topP, this.topK, this.maxTokens, + this.frequencyPenalty, this.presencePenalty, this.stopSequences); + } + } +} diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageGenerationMetadata.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageGenerationMetadata.java new file mode 100644 index 0000000..5aa8d64 --- /dev/null +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageGenerationMetadata.java @@ -0,0 +1,27 @@ +package qa.fanar.spring.ai; + +import java.util.Objects; + +import org.springframework.ai.image.ImageGenerationMetadata; + +/** + * Per-image generation metadata from Fanar: whether the prompt was auto-revised for style, + * quality, and cultural alignment, and the prompt actually used for generation. + * + *

      Retrieve via {@code imageResponse.getResult().getMetadata()} and narrow with + * {@code instanceof FanarImageGenerationMetadata} — the same access pattern other Spring AI + * providers use for their revised-prompt metadata.

      + * + * @param revised whether Fanar revised the prompt before generation + * @param revisedPrompt the prompt used for generation — equal to the request prompt when + * {@code revised} is {@code false} + * + * @author Oussama Mahjoub + */ +public record FanarImageGenerationMetadata(boolean revised, String revisedPrompt) + implements ImageGenerationMetadata { + + public FanarImageGenerationMetadata { + Objects.requireNonNull(revisedPrompt, "revisedPrompt"); + } +} diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageModel.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageModel.java index a3c6dad..2da3bd2 100644 --- a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageModel.java +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageModel.java @@ -10,6 +10,7 @@ import org.springframework.ai.image.ImageOptions; import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.ImageResponseMetadata; import qa.fanar.core.FanarClient; import qa.fanar.core.images.ImageGenerationRequest; @@ -35,6 +36,10 @@ *
    • URL response format. Fanar always returns {@code b64Json}; we expose only that.
    • *
    * + *

    Pass a {@link FanarImageOptions} to reach Fanar's {@code revise} flag (automatic prompt + * revision, server default {@code true}) — see ADR-024. Each result carries a + * {@link FanarImageGenerationMetadata} with the revision outcome.

    + * * @author Oussama Mahjoub */ public final class FanarImageModel implements ImageModel { @@ -57,11 +62,19 @@ public FanarImageModel(FanarClient fanar, qa.fanar.core.images.ImageModel defaul @Override public ImageResponse call(ImagePrompt prompt) { Objects.requireNonNull(prompt, "prompt"); - ImageGenerationRequest request = new ImageGenerationRequest(resolveModel(prompt), promptText(prompt)); + ImageGenerationRequest request = new ImageGenerationRequest( + resolveModel(prompt), promptText(prompt), resolveRevise(prompt)); ImageGenerationResponse fanarResponse = fanar.images().generate(request); return toSpringAiResponse(fanarResponse); } + private static Boolean resolveRevise(ImagePrompt prompt) { + // Fanar extra beyond the portable ImageOptions surface (ADR-024); null → server default. + return prompt.getOptions() instanceof FanarImageOptions fanarOptions + ? fanarOptions.getRevise() + : null; + } + private qa.fanar.core.images.ImageModel resolveModel(ImagePrompt prompt) { ImageOptions options = prompt.getOptions(); if (options != null && options.getModel() != null && !options.getModel().isBlank()) { @@ -90,8 +103,10 @@ private static String promptText(ImagePrompt prompt) { private static ImageResponse toSpringAiResponse(ImageGenerationResponse fanarResponse) { List generations = fanarResponse.data().stream() - .map(item -> new ImageGeneration(new Image(null, item.b64Json()))) + .map(item -> new ImageGeneration( + new Image(null, item.b64Json()), + new FanarImageGenerationMetadata(item.revised(), item.revisedPrompt()))) .toList(); - return new ImageResponse(generations); + return new ImageResponse(generations, new ImageResponseMetadata(fanarResponse.created())); } } diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageOptions.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageOptions.java new file mode 100644 index 0000000..075e791 --- /dev/null +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarImageOptions.java @@ -0,0 +1,83 @@ +package qa.fanar.spring.ai; + +import org.springframework.ai.image.ImageOptions; + +/** + * Fanar-specific {@link ImageOptions}: the standard portable knobs plus Fanar's {@code revise} + * flag (automatic prompt revision for style, quality, and cultural alignment — server default + * {@code true}). + * + *

    Pass an instance on the {@code ImagePrompt}; {@code FanarImageModel} maps + * {@link #getModel()} like any {@link ImageOptions} and additionally applies {@code revise}. + * The remaining portable getters (n, width, height, response format, style) have no Fanar wire + * field and are dropped, as documented on the adapter. Any other implementation keeps working — + * {@code revise} is then simply unset (ADR-024).

    + * + * @author Oussama Mahjoub + */ +public final class FanarImageOptions implements ImageOptions { + + private final String model; + private final Integer n; + private final Integer width; + private final Integer height; + private final String responseFormat; + private final String style; + private final Boolean revise; + + private FanarImageOptions(Builder b) { + this.model = b.model; + this.n = b.n; + this.width = b.width; + this.height = b.height; + this.responseFormat = b.responseFormat; + this.style = b.style; + this.revise = b.revise; + } + + /** Start a fresh builder. */ + public static Builder builder() { + return new Builder(); + } + + @Override public String getModel() { return model; } + @Override public Integer getN() { return n; } + @Override public Integer getWidth() { return width; } + @Override public Integer getHeight() { return height; } + @Override public String getResponseFormat() { return responseFormat; } + @Override public String getStyle() { return style; } + + /** + * Whether Fanar may auto-revise the prompt (server default {@code true}); {@code false} + * keeps the prompt verbatim, {@code null} accepts the default. + */ + public Boolean getRevise() { return revise; } + + /** Fluent builder; every field defaults to {@code null} ("use the adapter/server default"). */ + public static final class Builder { + + private String model; + private Integer n; + private Integer width; + private Integer height; + private String responseFormat; + private String style; + private Boolean revise; + + private Builder() { + // use FanarImageOptions.builder() + } + + public Builder model(String model) { this.model = model; return this; } + public Builder n(Integer n) { this.n = n; return this; } + public Builder width(Integer width) { this.width = width; return this; } + public Builder height(Integer height) { this.height = height; return this; } + public Builder responseFormat(String responseFormat) { this.responseFormat = responseFormat; return this; } + public Builder style(String style) { this.style = style; return this; } + public Builder revise(Boolean revise) { this.revise = revise; return this; } + + public FanarImageOptions build() { + return new FanarImageOptions(this); + } + } +} diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechModel.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechModel.java index 04dba8e..821a81a 100644 --- a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechModel.java +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechModel.java @@ -8,6 +8,7 @@ import org.springframework.ai.audio.tts.TextToSpeechOptions; import org.springframework.ai.audio.tts.TextToSpeechPrompt; import org.springframework.ai.audio.tts.TextToSpeechResponse; +import reactor.adapter.JdkFlowAdapter; import reactor.core.publisher.Flux; import qa.fanar.core.FanarClient; @@ -22,13 +23,16 @@ *

    Maps Spring AI's {@link TextToSpeechPrompt} + {@link TextToSpeechOptions} onto a Fanar * {@link TextToSpeechRequest} and returns the raw audio bytes wrapped as a {@link Speech}.

    * - *

    Streaming: Fanar's TTS endpoint returns the entire synthesized audio in one HTTP response — - * it does not chunk-stream. {@link #stream(TextToSpeechPrompt)} consequently emits exactly one - * {@link TextToSpeechResponse} containing the full audio, which is functionally equivalent to - * {@link #call(TextToSpeechPrompt)} but satisfies the {@code StreamingTextToSpeechModel} SPI.

    + *

    Streaming: {@link #stream(TextToSpeechPrompt)} uses Fanar's chunked delivery + * ({@code stream:true} on the wire) via {@code AudioClient.speechStream(...)} and emits one + * {@link TextToSpeechResponse} per audio chunk as the server generates it. Chunk boundaries + * follow transport reads — concatenate the chunks' bytes in emission order to reconstruct the + * full clip.

    * *

    Spring AI's {@link TextToSpeechOptions#getSpeed()} is silently dropped — Fanar's wire format - * has no playback-speed parameter. Callers can resample client-side after receiving the bytes.

    + * has no playback-speed parameter. Callers can resample client-side after receiving the bytes. + * Pass a {@link FanarTextToSpeechOptions} to reach the Fanar-only knobs (emotional synthesis, + * Quranic reciter) — see ADR-024.

    * * @author Oussama Mahjoub */ @@ -62,18 +66,25 @@ public TextToSpeechResponse call(TextToSpeechPrompt prompt) { @Override public Flux stream(TextToSpeechPrompt prompt) { - // Fanar's TTS returns the full audio in one HTTP body — no incremental streaming. Wrap - // the sync result into a single-element Flux so consumers using ChatClient's reactive - // path get a uniform shape regardless of provider. - return Flux.defer(() -> Flux.just(call(prompt))); + Objects.requireNonNull(prompt, "prompt"); + return Flux.defer(() -> JdkFlowAdapter.flowPublisherToFlux( + fanar.audio().speechStream(toFanarRequest(prompt))) + .map(chunk -> new TextToSpeechResponse(List.of(new Speech(chunk))))); } private TextToSpeechRequest toFanarRequest(TextToSpeechPrompt prompt) { TextToSpeechOptions options = prompt.getOptions(); - TtsModel model = resolveModel(options); - Voice voice = resolveVoice(options); - TtsResponseFormat format = resolveFormat(options); - return new TextToSpeechRequest(model, prompt.getInstructions().getText(), voice, format, null); + TextToSpeechRequest.Builder builder = TextToSpeechRequest.builder() + .model(resolveModel(options)) + .input(prompt.getInstructions().getText()) + .voice(resolveVoice(options)) + .responseFormat(resolveFormat(options)); + if (options instanceof FanarTextToSpeechOptions fanarOptions) { + // Fanar extras beyond the portable TextToSpeechOptions surface (ADR-024). + builder.withEmotion(fanarOptions.getWithEmotion()); + builder.quranReciter(fanarOptions.getQuranReciter()); + } + return builder.build(); } private TtsModel resolveModel(TextToSpeechOptions options) { diff --git a/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechOptions.java b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechOptions.java new file mode 100644 index 0000000..ab57ecf --- /dev/null +++ b/spring-ai-starter/src/main/java/qa/fanar/spring/ai/FanarTextToSpeechOptions.java @@ -0,0 +1,80 @@ +package qa.fanar.spring.ai; + +import org.springframework.ai.audio.tts.TextToSpeechOptions; + +import qa.fanar.core.audio.QuranReciter; + +/** + * Fanar-specific {@link TextToSpeechOptions}: the standard portable knobs plus the Fanar + * parameters portable options cannot carry — emotional synthesis and Quranic reciter selection. + * + *

    Pass an instance on the {@code TextToSpeechPrompt}; {@code FanarTextToSpeechModel} maps the + * portable getters like any {@link TextToSpeechOptions} and additionally applies the Fanar + * extras. Any other implementation keeps working — the extras are then simply unset + * (ADR-024). {@link #getSpeed()} remains unsupported by Fanar's wire format and is dropped.

    + * + * @author Oussama Mahjoub + */ +public final class FanarTextToSpeechOptions implements TextToSpeechOptions { + + private final String model; + private final String voice; + private final String format; + private final Double speed; + private final Boolean withEmotion; + private final QuranReciter quranReciter; + + private FanarTextToSpeechOptions(Builder b) { + this.model = b.model; + this.voice = b.voice; + this.format = b.format; + this.speed = b.speed; + this.withEmotion = b.withEmotion; + this.quranReciter = b.quranReciter; + } + + /** Start a fresh builder. */ + public static Builder builder() { + return new Builder(); + } + + @Override public String getModel() { return model; } + @Override public String getVoice() { return voice; } + @Override public String getFormat() { return format; } + @Override public Double getSpeed() { return speed; } + + /** + * Emotional speech synthesis ({@code Fanar-Aura-TTS-2} + emotion-capable voices only), + * or {@code null}. + */ + public Boolean getWithEmotion() { return withEmotion; } + + /** Reciter selection for {@code Fanar-Sadiq-TTS-1}, or {@code null}. */ + public QuranReciter getQuranReciter() { return quranReciter; } + + /** Fluent builder; every field defaults to {@code null} ("use the adapter/server default"). */ + public static final class Builder { + + private String model; + private String voice; + private String format; + private Double speed; + private Boolean withEmotion; + private QuranReciter quranReciter; + + private Builder() { + // use FanarTextToSpeechOptions.builder() + } + + public Builder model(String model) { this.model = model; return this; } + public Builder voice(String voice) { this.voice = voice; return this; } + public Builder format(String format) { this.format = format; return this; } + public Builder speed(Double speed) { this.speed = speed; return this; } + public Builder withEmotion(Boolean withEmotion) { this.withEmotion = withEmotion; return this; } + public Builder quranReciter(QuranReciter quranReciter) { this.quranReciter = quranReciter; return this; } + + public FanarTextToSpeechOptions build() { + return new FanarTextToSpeechOptions(this); + } + } +} diff --git a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatModelTest.java b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatModelTest.java index 938edf3..cace4cb 100644 --- a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatModelTest.java +++ b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatModelTest.java @@ -24,9 +24,14 @@ import qa.fanar.core.FanarClient; import qa.fanar.core.RetryPolicy; +import qa.fanar.core.chat.BookName; import qa.fanar.core.chat.ChatModel; +import qa.fanar.core.chat.Madhab; +import qa.fanar.core.chat.Source; import qa.fanar.json.jackson3.Jackson3FanarJsonCodec; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -115,6 +120,100 @@ void mixedRoleMessagesAllForwarded() { .contains("hi back"); } + @Test + void fanarChatOptionsForwardEveryVendorKnob() { + server.createContext("/v1/chat/completions", exchange -> { + capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + byte[] body = okResponse("ok"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(body); } + }); + server.start(); + client = clientFor(server); + + BookName book = BookName.KNOWN.iterator().next(); + FanarChatModel model = new FanarChatModel(client, ChatModel.FANAR); + FanarChatOptions options = FanarChatOptions.builder() + .model("Fanar-Sadiq-2") + .temperature(0.3) + .persona("Warm, patient teacher") + .madhab(List.of(Madhab.HANAFI, Madhab.ALL)) + .enableThinking(true) + .restrictToIslamic(true) + .bookNames(List.of(book)) + .preferredSources(List.of(Source.QURAN)) + .excludeSources(List.of(Source.DORAR)) + .filterSources(List.of(Source.TAFSIR)) + .logitBias(Map.of("50256", -100.0)) + .logprobs(true) + .topLogprobs(5) + .n(2) + .minP(0.05) + .repetitionPenalty(1.1) + .bestOf(3) + .lengthPenalty(1.2) + .earlyStopping(true) + .stopTokenIds(List.of(50256)) + .ignoreEos(false) + .minTokens(4) + .skipSpecialTokens(true) + .spacesBetweenSpecialTokens(false) + .truncatePromptTokens(2048) + .promptLogprobs(1) + .build(); + model.call(new Prompt(List.of(new UserMessage("x")), options)); + + assertThat(capturedRequestBody) + .contains("\"model\":\"Fanar-Sadiq-2\"") + .contains("\"temperature\":0.3") + .contains("\"persona\":\"Warm, patient teacher\"") + .contains("\"madhab\":[\"hanafi\",\"all\"]") + .contains("\"enable_thinking\":true") + .contains("\"restrict_to_islamic\":true") + .contains("\"book_names\":[\"" + book.wireValue() + "\"]") + .contains("\"preferred_sources\":[\"quran\"]") + .contains("\"exclude_sources\":[\"dorar\"]") + .contains("\"filter_sources\":[\"tafsir\"]") + .contains("\"logit_bias\":{\"50256\":-100.0}") + .contains("\"logprobs\":true") + .contains("\"top_logprobs\":5") + .contains("\"n\":2") + .contains("\"min_p\":0.05") + .contains("\"repetition_penalty\":1.1") + .contains("\"best_of\":3") + .contains("\"length_penalty\":1.2") + .contains("\"early_stopping\":true") + .contains("\"stop_token_ids\":[50256]") + .contains("\"ignore_eos\":false") + .contains("\"min_tokens\":4") + .contains("\"skip_special_tokens\":true") + .contains("\"spaces_between_special_tokens\":false") + .contains("\"truncate_prompt_tokens\":2048") + .contains("\"prompt_logprobs\":1"); + } + + @Test + void emptyFanarChatOptionsBehavesLikePortableDefaults() { + server.createContext("/v1/chat/completions", exchange -> { + capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + byte[] body = okResponse("ok"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(body); } + }); + server.start(); + client = clientFor(server); + + FanarChatModel model = new FanarChatModel(client, ChatModel.FANAR); + model.call(new Prompt(List.of(new UserMessage("x")), FanarChatOptions.builder().build())); + + assertThat(capturedRequestBody) + .contains("\"model\":\"Fanar\"") + .doesNotContain("persona") + .doesNotContain("madhab") + .doesNotContain("enable_thinking") + .doesNotContain("restrict_to_islamic"); + } + @Test void chatOptionsForwardSamplingKnobs() { server.createContext("/v1/chat/completions", exchange -> { diff --git a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatOptionsTest.java b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatOptionsTest.java new file mode 100644 index 0000000..55f841a --- /dev/null +++ b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarChatOptionsTest.java @@ -0,0 +1,288 @@ +package qa.fanar.spring.ai; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import qa.fanar.core.chat.BookName; +import qa.fanar.core.chat.Madhab; +import qa.fanar.core.chat.Source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FanarChatOptionsTest { + + private static final BookName BOOK = BookName.KNOWN.iterator().next(); + + @Test + void builderRoundtripsAllFields() { + FanarChatOptions o = FanarChatOptions.builder() + .model("Fanar-Sadiq-2") + .temperature(0.3) + .topP(0.9) + .topK(40) + .maxTokens(64) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .stopSequences(List.of("END")) + .persona("Warm, patient teacher") + .madhab(List.of(Madhab.HANAFI)) + .enableThinking(true) + .restrictToIslamic(true) + .bookNames(List.of(BOOK)) + .preferredSources(List.of(Source.QURAN)) + .excludeSources(List.of(Source.DORAR)) + .filterSources(List.of(Source.TAFSIR)) + .logitBias(Map.of("50256", -100.0)) + .logprobs(true) + .topLogprobs(5) + .n(2) + .minP(0.05) + .repetitionPenalty(1.1) + .bestOf(3) + .lengthPenalty(1.2) + .earlyStopping(true) + .stopTokenIds(List.of(50256)) + .ignoreEos(false) + .minTokens(8) + .skipSpecialTokens(true) + .spacesBetweenSpecialTokens(false) + .truncatePromptTokens(2048) + .promptLogprobs(1) + .build(); + + assertThat(o.getModel()).isEqualTo("Fanar-Sadiq-2"); + assertThat(o.getTemperature()).isEqualTo(0.3); + assertThat(o.getTopP()).isEqualTo(0.9); + assertThat(o.getTopK()).isEqualTo(40); + assertThat(o.getMaxTokens()).isEqualTo(64); + assertThat(o.getFrequencyPenalty()).isEqualTo(0.1); + assertThat(o.getPresencePenalty()).isEqualTo(0.2); + assertThat(o.getStopSequences()).containsExactly("END"); + assertThat(o.getPersona()).isEqualTo("Warm, patient teacher"); + assertThat(o.getMadhab()).containsExactly(Madhab.HANAFI); + assertThat(o.getEnableThinking()).isTrue(); + assertThat(o.getRestrictToIslamic()).isTrue(); + assertThat(o.getBookNames()).containsExactly(BOOK); + assertThat(o.getPreferredSources()).containsExactly(Source.QURAN); + assertThat(o.getExcludeSources()).containsExactly(Source.DORAR); + assertThat(o.getFilterSources()).containsExactly(Source.TAFSIR); + assertThat(o.getLogitBias()).containsEntry("50256", -100.0); + assertThat(o.getLogprobs()).isTrue(); + assertThat(o.getTopLogprobs()).isEqualTo(5); + assertThat(o.getN()).isEqualTo(2); + assertThat(o.getMinP()).isEqualTo(0.05); + assertThat(o.getRepetitionPenalty()).isEqualTo(1.1); + assertThat(o.getBestOf()).isEqualTo(3); + assertThat(o.getLengthPenalty()).isEqualTo(1.2); + assertThat(o.getEarlyStopping()).isTrue(); + assertThat(o.getStopTokenIds()).containsExactly(50256); + assertThat(o.getIgnoreEos()).isFalse(); + assertThat(o.getMinTokens()).isEqualTo(8); + assertThat(o.getSkipSpecialTokens()).isTrue(); + assertThat(o.getSpacesBetweenSpecialTokens()).isFalse(); + assertThat(o.getTruncatePromptTokens()).isEqualTo(2048); + assertThat(o.getPromptLogprobs()).isEqualTo(1); + } + + @Test + void unsetFieldsStayNull() { + FanarChatOptions o = FanarChatOptions.builder().build(); + assertThat(o.getModel()).isNull(); + assertThat(o.getTemperature()).isNull(); + assertThat(o.getTopP()).isNull(); + assertThat(o.getTopK()).isNull(); + assertThat(o.getMaxTokens()).isNull(); + assertThat(o.getFrequencyPenalty()).isNull(); + assertThat(o.getPresencePenalty()).isNull(); + assertThat(o.getStopSequences()).isNull(); + assertThat(o.getPersona()).isNull(); + assertThat(o.getMadhab()).isNull(); + assertThat(o.getEnableThinking()).isNull(); + assertThat(o.getRestrictToIslamic()).isNull(); + assertThat(o.getBookNames()).isNull(); + assertThat(o.getPreferredSources()).isNull(); + assertThat(o.getExcludeSources()).isNull(); + assertThat(o.getFilterSources()).isNull(); + assertThat(o.getLogitBias()).isNull(); + assertThat(o.getLogprobs()).isNull(); + assertThat(o.getTopLogprobs()).isNull(); + assertThat(o.getN()).isNull(); + assertThat(o.getMinP()).isNull(); + assertThat(o.getRepetitionPenalty()).isNull(); + assertThat(o.getBestOf()).isNull(); + assertThat(o.getLengthPenalty()).isNull(); + assertThat(o.getEarlyStopping()).isNull(); + assertThat(o.getStopTokenIds()).isNull(); + assertThat(o.getIgnoreEos()).isNull(); + assertThat(o.getMinTokens()).isNull(); + assertThat(o.getSkipSpecialTokens()).isNull(); + assertThat(o.getSpacesBetweenSpecialTokens()).isNull(); + assertThat(o.getTruncatePromptTokens()).isNull(); + assertThat(o.getPromptLogprobs()).isNull(); + } + + @Test + void collectionsAreDefensivelyCopiedAndUnmodifiable() { + List stop = new ArrayList<>(List.of("END")); + List madhab = new ArrayList<>(List.of(Madhab.MALIKI)); + List stopIds = new ArrayList<>(List.of(1)); + + FanarChatOptions o = FanarChatOptions.builder() + .stopSequences(stop) + .madhab(madhab) + .stopTokenIds(stopIds) + .build(); + + stop.add("MORE"); + madhab.add(Madhab.ALL); + stopIds.add(2); + + assertThat(o.getStopSequences()).containsExactly("END"); + assertThat(o.getMadhab()).containsExactly(Madhab.MALIKI); + assertThat(o.getStopTokenIds()).containsExactly(1); + assertThatThrownBy(() -> o.getMadhab().add(Madhab.SHAFII)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void mutateRoundTripsFanarExtrasThroughTheBuilder() { + // ChatClient rebuilds prompt options via mutate().build() — the Fanar extras must + // survive that round trip or they'd be silently dropped in the fluent pipeline. + FanarChatOptions original = FanarChatOptions.builder() + .model("Fanar-Sadiq-2") + .temperature(0.3) + .stopSequences(List.of("END")) + .persona("teacher") + .madhab(List.of(Madhab.HANBALI)) + .restrictToIslamic(true) + .build(); + + Object rebuilt = original.mutate().build(); + + assertThat(rebuilt).isInstanceOf(FanarChatOptions.class); + FanarChatOptions o = (FanarChatOptions) rebuilt; + assertThat(o.getModel()).isEqualTo("Fanar-Sadiq-2"); + assertThat(o.getTemperature()).isEqualTo(0.3); + assertThat(o.getStopSequences()).containsExactly("END"); + assertThat(o.getPersona()).isEqualTo("teacher"); + assertThat(o.getMadhab()).containsExactly(Madhab.HANBALI); + assertThat(o.getRestrictToIslamic()).isTrue(); + } + + @Test + void combineWithMergesFanarExtrasNonNullWins() { + FanarChatOptions.Builder base = FanarChatOptions.builder() + .persona("base persona") + .madhab(List.of(Madhab.HANAFI)) + .enableThinking(false) + .restrictToIslamic(true) + .bookNames(List.of(BOOK)) + .preferredSources(List.of(Source.QURAN)) + .excludeSources(List.of(Source.DORAR)) + .filterSources(List.of(Source.TAFSIR)) + .logitBias(Map.of("1", 1.0)) + .logprobs(false) + .topLogprobs(1) + .n(1) + .minP(0.01) + .repetitionPenalty(1.0) + .bestOf(1) + .lengthPenalty(1.0) + .earlyStopping(false) + .stopTokenIds(List.of(1)) + .ignoreEos(true) + .minTokens(1) + .skipSpecialTokens(false) + .spacesBetweenSpecialTokens(true) + .truncatePromptTokens(1) + .promptLogprobs(0) + .model("Fanar"); + + FanarChatOptions.Builder override = FanarChatOptions.builder() + .persona("override persona") + .madhab(List.of(Madhab.MALIKI)) + .enableThinking(true) + .restrictToIslamic(false) + .bookNames(List.of(BOOK)) + .preferredSources(List.of(Source.SUNNAH)) + .excludeSources(List.of(Source.SHAMELA)) + .filterSources(List.of(Source.ISLAMWEB)) + .logitBias(Map.of("2", 2.0)) + .logprobs(true) + .topLogprobs(2) + .n(2) + .minP(0.02) + .repetitionPenalty(2.0) + .bestOf(2) + .lengthPenalty(2.0) + .earlyStopping(true) + .stopTokenIds(List.of(2)) + .ignoreEos(false) + .minTokens(2) + .skipSpecialTokens(true) + .spacesBetweenSpecialTokens(false) + .truncatePromptTokens(2) + .promptLogprobs(1) + .model("Fanar-Sadiq-2"); + + FanarChatOptions merged = base.combineWith(override).build(); + + assertThat(merged.getModel()).isEqualTo("Fanar-Sadiq-2"); + assertThat(merged.getPersona()).isEqualTo("override persona"); + assertThat(merged.getMadhab()).containsExactly(Madhab.MALIKI); + assertThat(merged.getEnableThinking()).isTrue(); + assertThat(merged.getRestrictToIslamic()).isFalse(); + assertThat(merged.getPreferredSources()).containsExactly(Source.SUNNAH); + assertThat(merged.getExcludeSources()).containsExactly(Source.SHAMELA); + assertThat(merged.getFilterSources()).containsExactly(Source.ISLAMWEB); + assertThat(merged.getLogitBias()).containsEntry("2", 2.0); + assertThat(merged.getLogprobs()).isTrue(); + assertThat(merged.getTopLogprobs()).isEqualTo(2); + assertThat(merged.getN()).isEqualTo(2); + assertThat(merged.getMinP()).isEqualTo(0.02); + assertThat(merged.getRepetitionPenalty()).isEqualTo(2.0); + assertThat(merged.getBestOf()).isEqualTo(2); + assertThat(merged.getLengthPenalty()).isEqualTo(2.0); + assertThat(merged.getEarlyStopping()).isTrue(); + assertThat(merged.getStopTokenIds()).containsExactly(2); + assertThat(merged.getIgnoreEos()).isFalse(); + assertThat(merged.getMinTokens()).isEqualTo(2); + assertThat(merged.getSkipSpecialTokens()).isTrue(); + assertThat(merged.getSpacesBetweenSpecialTokens()).isFalse(); + assertThat(merged.getTruncatePromptTokens()).isEqualTo(2); + assertThat(merged.getPromptLogprobs()).isEqualTo(1); + } + + @Test + void combineWithEmptyFanarBuilderKeepsBaseValues() { + FanarChatOptions.Builder base = FanarChatOptions.builder() + .persona("kept") + .madhab(List.of(Madhab.SHAFII)) + .model("Fanar-Sadiq"); + + FanarChatOptions merged = base.combineWith(FanarChatOptions.builder()).build(); + + assertThat(merged.getPersona()).isEqualTo("kept"); + assertThat(merged.getMadhab()).containsExactly(Madhab.SHAFII); + assertThat(merged.getModel()).isEqualTo("Fanar-Sadiq"); + assertThat(merged.getEnableThinking()).isNull(); + } + + @Test + void combineWithPortableBuilderTouchesOnlyPortableFields() { + FanarChatOptions.Builder base = FanarChatOptions.builder() + .persona("kept"); + + // A plain Spring AI builder carries no Fanar extras — the instanceof branch is skipped. + FanarChatOptions merged = base + .combineWith(org.springframework.ai.chat.prompt.ChatOptions.builder().temperature(0.7)) + .build(); + + assertThat(merged.getPersona()).isEqualTo("kept"); + assertThat(merged.getTemperature()).isEqualTo(0.7); + } +} diff --git a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageModelTest.java b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageModelTest.java index 3bc8b4b..30b1830 100644 --- a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageModelTest.java +++ b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageModelTest.java @@ -59,7 +59,8 @@ void callForwardsPromptAndDecodesB64Json() { capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); byte[] body = """ {"id":"img-1","created":1700000000, - "data":[{"b64_json":"AAAA"}]} + "data":[{"b64_json":"AAAA","revised":true, + "revised_prompt":"a refined calligraphy mosque"}]} """.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, body.length); @@ -74,17 +75,40 @@ void callForwardsPromptAndDecodesB64Json() { assertThat(response.getResults()).hasSize(1); assertThat(response.getResult().getOutput().getB64Json()).isEqualTo("AAAA"); assertThat(response.getResult().getOutput().getUrl()).isNull(); + assertThat(response.getResult().getMetadata()) + .isEqualTo(new FanarImageGenerationMetadata(true, "a refined calligraphy mosque")); + assertThat(response.getMetadata().getCreated()).isEqualTo(1_700_000_000L); assertThat(capturedRequestBody) .contains("\"prompt\":\"a calligraphy mosque\"") .contains("\"model\":\"Fanar-Oryx-IG-2\""); } + @Test + void fanarImageOptionsForwardReviseFlag() { + server.createContext("/v1/images/generations", exchange -> { + capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + byte[] body = """ + {"id":"img","created":1,"data":[{"b64_json":"x","revised":false,"revised_prompt":"p"}]} + """.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(body); } + }); + server.start(); + client = clientFor(server); + + FanarImageModel model = new FanarImageModel(client, ImageModel.FANAR_ORYX_IG_2); + model.call(new ImagePrompt("a mosque", + FanarImageOptions.builder().revise(false).build())); + + assertThat(capturedRequestBody).contains("\"revise\":false"); + } + @Test void multiMessagePromptsAreJoinedWithNewlines() { server.createContext("/v1/images/generations", exchange -> { capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); byte[] body = """ - {"id":"img","created":1,"data":[{"b64_json":"x"}]} + {"id":"img","created":1,"data":[{"b64_json":"x","revised":false,"revised_prompt":"p"}]} """.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); try (OutputStream out = exchange.getResponseBody()) { out.write(body); } @@ -106,7 +130,7 @@ void imageOptionsModelOverridesDefault() { server.createContext("/v1/images/generations", exchange -> { capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); byte[] body = """ - {"id":"img","created":1,"data":[{"b64_json":"x"}]} + {"id":"img","created":1,"data":[{"b64_json":"x","revised":false,"revised_prompt":"p"}]} """.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); try (OutputStream out = exchange.getResponseBody()) { out.write(body); } @@ -129,7 +153,7 @@ void blankModelOptionFallsBackToDefault() { server.createContext("/v1/images/generations", exchange -> { capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); byte[] body = """ - {"id":"img","created":1,"data":[{"b64_json":"x"}]} + {"id":"img","created":1,"data":[{"b64_json":"x","revised":false,"revised_prompt":"p"}]} """.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); try (OutputStream out = exchange.getResponseBody()) { out.write(body); } diff --git a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageOptionsTest.java b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageOptionsTest.java new file mode 100644 index 0000000..17f2c2d --- /dev/null +++ b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarImageOptionsTest.java @@ -0,0 +1,41 @@ +package qa.fanar.spring.ai; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FanarImageOptionsTest { + + @Test + void builderRoundtripsAllFields() { + FanarImageOptions o = FanarImageOptions.builder() + .model("Fanar-Oryx-IG-2") + .n(1) + .width(1024) + .height(768) + .responseFormat("b64_json") + .style("photorealistic") + .revise(false) + .build(); + + assertThat(o.getModel()).isEqualTo("Fanar-Oryx-IG-2"); + assertThat(o.getN()).isEqualTo(1); + assertThat(o.getWidth()).isEqualTo(1024); + assertThat(o.getHeight()).isEqualTo(768); + assertThat(o.getResponseFormat()).isEqualTo("b64_json"); + assertThat(o.getStyle()).isEqualTo("photorealistic"); + assertThat(o.getRevise()).isFalse(); + } + + @Test + void unsetFieldsStayNull() { + FanarImageOptions o = FanarImageOptions.builder().build(); + assertThat(o.getModel()).isNull(); + assertThat(o.getN()).isNull(); + assertThat(o.getWidth()).isNull(); + assertThat(o.getHeight()).isNull(); + assertThat(o.getResponseFormat()).isNull(); + assertThat(o.getStyle()).isNull(); + assertThat(o.getRevise()).isNull(); + } +} diff --git a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechModelTest.java b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechModelTest.java index 9c8861b..edbe83d 100644 --- a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechModelTest.java +++ b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechModelTest.java @@ -1,5 +1,6 @@ package qa.fanar.spring.ai; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.InetAddress; @@ -19,6 +20,7 @@ import qa.fanar.core.FanarClient; import qa.fanar.core.RetryPolicy; +import qa.fanar.core.audio.QuranReciter; import qa.fanar.core.audio.TtsModel; import qa.fanar.core.audio.Voice; import qa.fanar.json.jackson3.Jackson3FanarJsonCodec; @@ -79,8 +81,35 @@ void callForwardsTextAndReturnsAudioBytes() { } @Test - void streamEmitsSingleResponseWithTheSameAudio() { + void fanarOptionsForwardEmotionAndReciter() { server.createContext("/v1/audio/speech", exchange -> { + capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, FAKE_AUDIO.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(FAKE_AUDIO); } + }); + server.start(); + client = clientFor(server); + + FanarTextToSpeechModel model = new FanarTextToSpeechModel( + client, TtsModel.FANAR_AURA_TTS_2, Voice.AMELIA); + model.call(new TextToSpeechPrompt("hello", FanarTextToSpeechOptions.builder() + .voice("Radwa") + .format("wav") + .withEmotion(true) + .quranReciter(QuranReciter.MAHER_AL_MUAIQLY) + .build())); + + assertThat(capturedRequestBody) + .contains("\"voice\":\"Radwa\"") + .contains("\"response_format\":\"wav\"") + .contains("\"with_emotion\":true") + .contains("\"quran_reciter\":\"" + QuranReciter.MAHER_AL_MUAIQLY.wireValue() + "\""); + } + + @Test + void streamEmitsChunksThatConcatenateToTheAudio() { + server.createContext("/v1/audio/speech", exchange -> { + capturedRequestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, FAKE_AUDIO.length); try (OutputStream out = exchange.getResponseBody()) { out.write(FAKE_AUDIO); } }); @@ -89,12 +118,15 @@ void streamEmitsSingleResponseWithTheSameAudio() { FanarTextToSpeechModel model = new FanarTextToSpeechModel( client, TtsModel.FANAR_AURA_TTS_2, Voice.AMELIA); - // Fanar's TTS isn't a true stream — the adapter wraps the one-shot result as a - // single-element Flux. Asserting cardinality + content matches the call() path. + // Real chunked streaming: one TextToSpeechResponse per transport read, concatenating + // to the full clip. The wire request must carry the spliced stream flag. var chunks = model.stream(new TextToSpeechPrompt("hi")).collectList().block(Duration.ofSeconds(5)); - assertThat(chunks).hasSize(1); - assertThat(chunks.getFirst().getResult().getOutput()).isEqualTo(FAKE_AUDIO); + assertThat(chunks).isNotEmpty(); + ByteArrayOutputStream collected = new ByteArrayOutputStream(); + chunks.forEach(c -> collected.writeBytes(c.getResult().getOutput())); + assertThat(collected.toByteArray()).isEqualTo(FAKE_AUDIO); + assertThat(capturedRequestBody).contains("\"stream\":true"); } @Test diff --git a/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechOptionsTest.java b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechOptionsTest.java new file mode 100644 index 0000000..b1ce088 --- /dev/null +++ b/spring-ai-starter/src/test/java/qa/fanar/spring/ai/FanarTextToSpeechOptionsTest.java @@ -0,0 +1,41 @@ +package qa.fanar.spring.ai; + +import org.junit.jupiter.api.Test; + +import qa.fanar.core.audio.QuranReciter; + +import static org.assertj.core.api.Assertions.assertThat; + +class FanarTextToSpeechOptionsTest { + + @Test + void builderRoundtripsAllFields() { + FanarTextToSpeechOptions o = FanarTextToSpeechOptions.builder() + .model("Fanar-Sadiq-TTS-1") + .voice("Radwa") + .format("wav") + .speed(1.25) + .withEmotion(true) + .quranReciter(QuranReciter.MAHER_AL_MUAIQLY) + .build(); + + assertThat(o.getModel()).isEqualTo("Fanar-Sadiq-TTS-1"); + assertThat(o.getVoice()).isEqualTo("Radwa"); + assertThat(o.getFormat()).isEqualTo("wav"); + assertThat(o.getSpeed()).isEqualTo(1.25); + assertThat(o.getWithEmotion()).isTrue(); + assertThat(o.getQuranReciter()).isEqualTo(QuranReciter.MAHER_AL_MUAIQLY); + } + + @Test + void unsetFieldsStayNull() { + FanarTextToSpeechOptions o = FanarTextToSpeechOptions.builder().build(); + assertThat(o.getModel()).isNull(); + assertThat(o.getVoice()).isNull(); + assertThat(o.getFormat()).isNull(); + assertThat(o.getSpeed()).isNull(); + assertThat(o.getWithEmotion()).isNull(); + assertThat(o.getQuranReciter()).isNull(); + } + +}