-
Notifications
You must be signed in to change notification settings - Fork 397
[feat] Fireworks AI Backend for EvalOnlyEntrypoint #1866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
84c2661
update deps
kyuds 7ca392e
implement fireworks eval-only
kyuds ce8fbe0
fireworks serverless has gpt-oss 20B
kyuds 7d02a15
test utility hidden behind kwargs
kyuds 71b7678
new method to set tokenizer name for eval only
kyuds dd49dd8
fix logprobs and format
kyuds 6453852
Merge branch 'main' into fireworks-gen
kyuds File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| set -x | ||
|
|
||
| # Evaluation-only generation for GSM8K against the external Fireworks endpoint | ||
| # (generator.inference_engine.backend=fireworks). No local inference engines and | ||
| # no vLLM: prompts are sent as token ids and Fireworks returns the generated | ||
| # token ids (return_token_ids), so the stock generator works unchanged. | ||
| # | ||
| # hf_tokenizer_name must be the served model's tokenizer (token ids are sent | ||
| # raw), and served_model_name is the Fireworks model id. | ||
| # trainer.policy.model.path is not used by this backend. | ||
|
|
||
| # uv run examples/train/gsm8k/gsm8k_dataset.py --output_dir $HOME/data/gsm8k | ||
| # export FIREWORKS_AI_API_KEY=<your_key_here> | ||
| # bash examples/eval/run_eval_fireworks.sh | ||
|
|
||
| : "${FIREWORKS_AI_API_KEY:?export FIREWORKS_AI_API_KEY first}" | ||
|
|
||
| DATA_DIR="$HOME/data/gsm8k" | ||
| TOKENIZER="openai/gpt-oss-20b" | ||
| FW_MODEL="accounts/fireworks/models/gpt-oss-20b" | ||
| LOGGER="console" | ||
|
|
||
| uv run --isolated --extra fireworks \ | ||
| -m skyrl.train.entrypoints.main_generate \ | ||
| data.val_data="['$DATA_DIR/validation.parquet']" \ | ||
| trainer.logger="$LOGGER" \ | ||
| trainer.placement.colocate_all=false \ | ||
| generator.inference_engine.backend=fireworks \ | ||
| generator.inference_engine.run_engines_locally=false \ | ||
| generator.inference_engine.served_model_name="$FW_MODEL" \ | ||
| generator.inference_engine.hf_tokenizer_name="$TOKENIZER" \ | ||
| generator.inference_engine.api_key="$FIREWORKS_AI_API_KEY" \ | ||
| generator.eval_sampling_params.max_generate_length=2048 \ | ||
| generator.eval_sampling_params.temperature=0.7 \ | ||
| environment.env_class=gsm8k \ | ||
| "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
217 changes: 217 additions & 0 deletions
217
skyrl/backends/skyrl_train/inference_servers/fireworks_client.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| """External Fireworks inference client (generation/eval only). | ||
|
|
||
| Fireworks' OpenAI-compatible ``/completions`` accepts a pre-tokenized integer-array ``prompt`` | ||
| and, with ``return_token_ids=true``, returns the generated integer ``token_ids``. This gives | ||
| token-in/token-out against an external endpoint with no re-tokenization drift, so the stock | ||
| ``SkyRLGymGenerator`` works unchanged. | ||
|
|
||
| Built on the Fireworks v1 SDK (``fireworks-ai``, installed via the ``fireworks`` uv extra): | ||
| ``prompt`` accepts ``Iterable[Iterable[int]]``, ``return_token_ids`` is a first-class request | ||
| param, and the response ``Choice`` declares ``token_ids``/``prompt_token_ids``. The SDK carries | ||
| auth, retries with backoff, timeouts, and connection pooling. This module does not import vllm | ||
| and has no control plane: wake/sleep/etc. are no-ops, weight sync raises. Only the eval-only | ||
| entrypoint builds this client (training entrypoints reject ``backend='fireworks'``). | ||
| """ | ||
|
|
||
| from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple | ||
|
|
||
| import httpx | ||
| from fireworks import AsyncFireworks | ||
|
|
||
| from skyrl.backends.skyrl_train.inference_servers.base import ( | ||
| InferenceEngineInput, | ||
| InferenceEngineInterface, | ||
| InferenceEngineOutput, | ||
| ) | ||
|
|
||
| if TYPE_CHECKING: | ||
| from transformers import PreTrainedTokenizerBase | ||
|
|
||
| _GEN_EVAL_ONLY = ( | ||
| "Fireworks is a generation/eval-only backend (external hosted endpoint with no weight sync). " | ||
| "Use backend='vllm' for training." | ||
| ) | ||
|
|
||
| # Server root of the Fireworks data plane. The SDK appends `/v1/completions` to an overridden | ||
| # base_url, so this must NOT end in `/v1` (validate_inference_engine_cfg guards user-supplied | ||
| # values). | ||
| DEFAULT_FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference" | ||
|
|
||
|
|
||
| class FireworksInferenceClient(InferenceEngineInterface): | ||
| def __init__( | ||
| self, | ||
| model_name: str, | ||
| tokenizer: "PreTrainedTokenizerBase", | ||
| base_url: Optional[str] = None, | ||
| api_key: Optional[str] = None, | ||
| max_retries: int = 3, | ||
| request_timeout: float = 600.0, | ||
| *, | ||
| _http_client: Optional[httpx.AsyncClient] = None, | ||
| ): | ||
| """Args: | ||
| model_name: Fireworks model id used as the request ``model`` (e.g. | ||
| ``accounts/fireworks/models/gpt-oss-20b``). | ||
| tokenizer: The policy tokenizer; must be the served model's tokenizer since prompts | ||
| are sent as raw token ids. | ||
| base_url: Server root without ``/v1`` (defaults to the Fireworks data plane). | ||
| api_key: API key sent as ``Authorization: Bearer``. Always passed explicitly so the | ||
| SDK never falls back to the ``FIREWORKS_API_KEY`` env var; ``"EMPTY"`` placeholder | ||
| keeps keyless self-hosted endpoints constructible. | ||
| max_retries: SDK retry budget (backoff on 408/409/429/5xx and ``x-should-retry``). | ||
| request_timeout: Per-request timeout in seconds. Overrides the SDK's 60s default, | ||
| which is too short for long generations. | ||
| _http_client: Internal-reserved injectable httpx client, used by tests with | ||
| ``httpx.MockTransport``. | ||
| """ | ||
| self._base_url = (base_url or DEFAULT_FIREWORKS_BASE_URL).rstrip("/") | ||
| self._model_name = model_name | ||
| self._tokenizer = tokenizer | ||
| self._client = AsyncFireworks( | ||
| base_url=self._base_url, | ||
| api_key=api_key or "EMPTY", | ||
| max_retries=max_retries, | ||
| timeout=request_timeout, | ||
| http_client=_http_client, | ||
| ) | ||
|
|
||
| @property | ||
| def model_name(self) -> str: | ||
| return self._model_name | ||
|
|
||
| def get_endpoint_url(self) -> str: | ||
| return self._base_url | ||
|
|
||
| async def generate( | ||
| self, | ||
| input_batch: InferenceEngineInput, | ||
| model: Optional[str] = None, | ||
| ) -> InferenceEngineOutput: | ||
| prompt_token_ids = input_batch.get("prompt_token_ids") | ||
| if prompt_token_ids is None: | ||
| raise ValueError("FireworksInferenceClient only accepts `prompt_token_ids`, not `prompts`.") | ||
| if input_batch.get("mm_features"): | ||
| raise NotImplementedError("FireworksInferenceClient does not support multi-modal features.") | ||
|
|
||
| sampling_params = dict(input_batch.get("sampling_params") or {}) | ||
| if sampling_params.get("n", 1) > 1: | ||
| raise ValueError("n > 1 is not supported. Use `config.generator.n_samples_per_prompt` instead.") | ||
| want_logprobs = sampling_params.get("logprobs") is not None | ||
|
|
||
| # model/prompt/return_token_ids are typed SDK params; the sampling dict rides extra_body | ||
| # (merged into the JSON request body) so additional_kwargs passthrough keeps working. | ||
| completion = await self._client.completions.create( | ||
| model=model or self._model_name, | ||
| prompt=prompt_token_ids, | ||
| return_token_ids=True, | ||
| extra_body=sampling_params, | ||
| ) | ||
| if not completion.choices: | ||
| raise RuntimeError(f"Fireworks returned no choices: {completion!r}") | ||
| choices = sorted(completion.choices, key=lambda choice: choice.index) | ||
|
|
||
| response_ids: List[List[int]] = [] | ||
| responses: List[str] = [] | ||
| stop_reasons: List[str] = [] | ||
| response_logprobs: List[Optional[List[float]]] = [] | ||
| for choice in choices: | ||
| token_ids = choice.token_ids | ||
| # Re-encoding `choice.text` locally would silently reintroduce the retokenization | ||
| # drift this backend exists to avoid, so a missing field is a hard error. | ||
| assert token_ids is not None, ( | ||
| f"Fireworks response missing `token_ids` for choice {choice.index} despite " "return_token_ids=true." | ||
| ) | ||
| response_ids.append(list(token_ids)) | ||
| # Decode locally to guarantee the InferenceEngineOutput invariant: | ||
| # tokenizer.decode(response_ids[i], skip_special_tokens=True) == responses[i]. | ||
| responses.append(self._tokenizer.decode(token_ids, skip_special_tokens=True)) | ||
| stop_reasons.append(choice.finish_reason or "stop") | ||
| if want_logprobs: | ||
| logprobs = self._extract_logprobs(choice) | ||
| # Silently emitting None here would surface far downstream as a confusing | ||
| # length-validation failure on GeneratorOutput["rollout_logprobs"]. | ||
| if logprobs is None: | ||
| raise RuntimeError( | ||
| f"Sampling params requested logprobs but Fireworks returned none (or an " | ||
| f"unrecognized shape) for choice {choice.index}. Set " | ||
| f"generator.eval_sampling_params.logprobs=null (and " | ||
| f"generator.sampling_params.logprobs=null) if logprobs are not needed." | ||
| ) | ||
| response_logprobs.append(logprobs) | ||
|
|
||
| return InferenceEngineOutput( | ||
| responses=responses, | ||
| response_ids=response_ids, | ||
| stop_reasons=stop_reasons, | ||
| response_logprobs=response_logprobs if want_logprobs else None, | ||
| prompt_logprobs=None, | ||
| rollout_expert_indices=None, | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _extract_logprobs(choice: Any) -> Optional[List[float]]: | ||
| """Extract per-token logprobs from either Fireworks response shape. | ||
|
|
||
| ``choice.logprobs`` is a union of the classic completions shape (``LogProbs``, carries | ||
| ``token_logprobs``; what the live endpoint returns for integer ``logprobs``) and the | ||
| OpenAI chat-style shape (``NewLogProbs``, carries ``content`` items with ``.logprob``). | ||
| Null entries map to 0.0 rather than being dropped so the result stays aligned 1:1 with | ||
| the generated token ids (downstream validation asserts equal lengths). | ||
| """ | ||
| logprobs = choice.logprobs | ||
| if logprobs is None: | ||
| return None | ||
| token_logprobs = getattr(logprobs, "token_logprobs", None) | ||
| if token_logprobs is not None: | ||
| return [logprob if logprob is not None else 0.0 for logprob in token_logprobs] | ||
| content = getattr(logprobs, "content", None) | ||
| if content is not None: | ||
| return [item.logprob if item.logprob is not None else 0.0 for item in content] | ||
| return None | ||
|
|
||
|
kyuds marked this conversation as resolved.
|
||
| async def completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | ||
| response = await self._client.completions.create(**request_payload.get("json", {})) | ||
| return response.model_dump() | ||
|
|
||
| async def chat_completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | ||
| response = await self._client.chat.completions.create(**request_payload.get("json", {})) | ||
| return response.model_dump() | ||
|
|
||
| async def render_chat_completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]: | ||
| raise NotImplementedError("render_chat_completion is not supported for the Fireworks backend.") | ||
|
|
||
| async def wake_up(self, *args: Any, **kwargs: Any): | ||
| # TODO: tokenizer handshake — probe with a text prompt + return_token_ids=true and | ||
| # compare the returned prompt_token_ids against a local tokenizer.encode() of the same | ||
| # string. A mispaired hf_tokenizer_name / served_model_name fails silently otherwise: | ||
| # the server consumes raw token ids, so a wrong tokenizer yields degraded generations, | ||
| # never an error. | ||
| return {} | ||
|
|
||
| async def sleep(self, *args: Any, **kwargs: Any): | ||
| return {} | ||
|
|
||
| async def reset_prefix_cache(self, reset_running_requests: bool = False): | ||
| return {} | ||
|
|
||
| async def pause_generation(self) -> None: | ||
| return | ||
|
|
||
| async def resume_generation(self) -> None: | ||
| return | ||
|
|
||
| async def finish_session(self, session_id: str) -> None: | ||
| return | ||
|
|
||
| async def teardown(self): | ||
| await self._client.close() | ||
|
|
||
| async def get_world_size(self) -> Tuple[int, int]: | ||
| raise NotImplementedError(_GEN_EVAL_ONLY) | ||
|
|
||
| async def init_weight_update_communicator(self, init_info): | ||
| raise NotImplementedError(_GEN_EVAL_ONLY) | ||
|
|
||
| async def update_named_weights(self, request): | ||
| raise NotImplementedError(_GEN_EVAL_ONLY) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.